Repository: solana-labs/solana-program-library
Pull request #5667 (2023-10-26 10:02:43)

Adding Token Metadata Support to @solana/spl-token 
Addresses #5229 

> With the addition of token metadata to Token2022 and the SPL Token Metadata interface itself to the SPL library, we need to support this cool feature & interface in JavaScript!
> 
> The goal here is to lay down a JavaScript library for SPL Token Metadata and then import it into @solana/spl-token for Token2022 metadata support.
> 
> This issue is importing and implementing @solana/spl-token-metadata.


mistersimon: > This is moving along nicely! Let's iron out the `MetadataPointer` PR first and then rebase this one on top, then I can give this another look.
> 
> Thanks for the continued effort!

Sounds like a good idea. Once the `MetadataPointer` PR is merged, I'll come back to this - rebase and address your comments. Then if you can take a look then before i go add the other instructions, would be really helpful. 
buffalojoec: Hey @mistersimon as usually I really appreciate the continued effort!

I wanted to reach out and see if you were interested in continuing this PR, or if you'd like me to take it over. I only ask because we may need this feature soon.

If I don't hear from you by the weekend I'll probably build off what you've got and wrap this thing up, not a problem! Again, thanks!
mistersimon: > Hey @mistersimon as usually I really appreciate the continued effort!
> 
> I wanted to reach out and see if you were interested in continuing this PR, or if you'd like me to take it over. I only ask because we may need this feature soon.
> 
> If I don't hear from you by the weekend I'll probably build off what you've got and wrap this thing up, not a problem! Again, thanks!

Hey @buffalojoec - thanks for reaching out. 

Yeah have been a bit busy with other stuff and haven't had as much time to work on this as I had hope. I have carved out some time this weekend though to finish up the outstanding issues.

But completely understand you all have your own schedule, and i have taken a while to finish this. No worries if you feel the need to take over - just let me know! 
buffalojoec: > Yeah have been a bit busy with other stuff and haven't had as much time to work on this as I had hope. I have carved out some time this weekend though to finish up the outstanding issues.
> 
> But completely understand you all have your own schedule, and i have taken a while to finish this. No worries if you feel the need to take over - just let me know!

If you've got the time and you want to give it another go this weekend I'm totally cool with that!

If at any point you decide you're too busy or otherwise don't want to keep hammering on this, just throw a flare up.
mistersimon: Hey @buffalojoec - think i've picked up all your edits. Feel free to take a look and let me know if theres anything still left to do. 

I'll keep some time free next week to pick them up and get this closed out!


buffalojoec: Hey @mistersimon thanks for the awesome work!

This PR was just about ready to go, but in the interest of time I did the little bit of cleanup and also refactored the tests a bit.

Patch:
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a0cce6f50..b4338b65c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1,5 +1,9 @@
 lockfileVersion: '6.0'
 
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
 importers:
 
   .:
@@ -722,10 +726,16 @@ importers:
       '@solana/buffer-layout-utils':
         specifier: ^0.2.0
         version: 0.2.0
+      '@solana/spl-token-metadata':
+        specifier: ^0.1.2
+        version: link:../../token-metadata/js
       buffer:
         specifier: ^6.0.3
         version: 6.0.3
     devDependencies:
+      '@solana/codecs-strings':
+        specifier: 2.0.0-experimental.9741939
+        version: 2.0.0-experimental.9741939(fastestsmallesttextencoderdecoder@1.0.22)
       '@solana/spl-memo':
         specifier: 0.2.3
         version: link:../../memo/js
@@ -2271,7 +2281,6 @@ packages:
 
   /@solana/codecs-core@2.0.0-experimental.9741939:
     resolution: {integrity: sha512-7E51aEwLW+1ta6VWbEq0CbvwSUOcIwmaQCLpkOKsF6cwSNqE5GDv/jw9zKuiYOynlBFQO1Ws59a/hlb2wwwWKg==}
-    dev: false
 
   /@solana/codecs-data-structures@2.0.0-experimental.9741939:
     resolution: {integrity: sha512-Ghjx0pFEA22T/cpqg3zXHYlnxqKytjTr1QjgfhWvBrlVFkqr3ZQL4av9/V6sHKvpoi+JNkEwM1wE+tlpt/54CA==}
@@ -2284,7 +2293,6 @@ packages:
     resolution: {integrity: sha512-VXBvw8LZdGUJGuC33EFzvaUxCvgNtr9y9Td8zjK9wJDqKSzR0+G53CJv5K4AF25LUu8du8XHBK3VEz3YHl44nQ==}
     dependencies:
       '@solana/codecs-core': 2.0.0-experimental.9741939
-    dev: false
 
   /@solana/codecs-strings@2.0.0-experimental.9741939(fastestsmallesttextencoderdecoder@1.0.22):
     resolution: {integrity: sha512-J1DCTJsAMhFcIIvys/yNWM2Vr4HEWi+LzFB9xxdH+FHTynApjTMd4vI7frgnckuWH7ynp8EZFPnBTje1BphQrw==}
@@ -2294,7 +2302,6 @@ packages:
       '@solana/codecs-core': 2.0.0-experimental.9741939
       '@solana/codecs-numbers': 2.0.0-experimental.9741939
       fastestsmallesttextencoderdecoder: 1.0.22
-    dev: false
 
   /@solana/eslint-config-solana@1.0.2(@typescript-eslint/eslint-plugin@6.14.0)(@typescript-eslint/parser@6.14.0)(eslint-plugin-jest@27.6.0)(eslint-plugin-react-hooks@4.6.0)(eslint-plugin-simple-import-sort@10.0.0)(eslint-plugin-sort-keys-fix@1.1.2)(eslint@8.55.0)(typescript@5.3.3):
     resolution: {integrity: sha512-/mBi2xFsX0fq3ukZ3ugA+Zh2+Ct+bh713ZSdOJAv1G7ARzyOEDxmiUz8TLzHu4joOC/3Ok4Yl7cjupOcIRzMHw==}
@@ -4582,7 +4589,6 @@ packages:
 
   /fastestsmallesttextencoderdecoder@1.0.22:
     resolution: {integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==}
-    dev: false
 
   /fastq@1.15.0:
     resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==}
@@ -8011,7 +8017,3 @@ packages:
     resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
     engines: {node: '>=10'}
     dev: true
-
-settings:
-  autoInstallPeers: true
-  excludeLinksFromLockfile: false
diff --git a/token/js/package.json b/token/js/package.json
index b8641f460..06dab9892 100644
--- a/token/js/package.json
+++ b/token/js/package.json
@@ -56,9 +56,11 @@
     "dependencies": {
         "@solana/buffer-layout": "^4.0.0",
         "@solana/buffer-layout-utils": "^0.2.0",
+        "@solana/spl-token-metadata": "^0.1.2",
         "buffer": "^6.0.3"
     },
     "devDependencies": {
+        "@solana/codecs-strings": "2.0.0-experimental.9741939",
         "@solana/spl-memo": "0.2.3",
         "@solana/web3.js": "^1.87.6",
         "@types/chai-as-promised": "^7.1.4",
diff --git a/token/js/src/extensions/extensionType.ts b/token/js/src/extensions/extensionType.ts
index bec17bda8..7dabbc6fb 100644
--- a/token/js/src/extensions/extensionType.ts
+++ b/token/js/src/extensions/extensionType.ts
@@ -1,6 +1,8 @@
+import type { AccountInfo, PublicKey } from '@solana/web3.js';
+
 import { ACCOUNT_SIZE } from '../state/account.js';
 import type { Mint } from '../state/mint.js';
-import { MINT_SIZE } from '../state/mint.js';
+import { MINT_SIZE, unpackMint } from '../state/mint.js';
 import { MULTISIG_SIZE } from '../state/multisig.js';
 import { ACCOUNT_TYPE_SIZE } from './accountType.js';
 import { CPI_GUARD_SIZE } from './cpiGuard/index.js';
@@ -14,6 +16,7 @@ import { NON_TRANSFERABLE_SIZE, NON_TRANSFERABLE_ACCOUNT_SIZE } from './nonTrans
 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';
+import { TOKEN_2022_PROGRAM_ID } from '../constants.js';
 
 // Sequence from https://github.com/solana-labs/solana-program-library/blob/master/token/program-2022/src/extension/mod.rs#L903
 export enum ExtensionType {
@@ -36,11 +39,16 @@ 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;
 export const LENGTH_SIZE = 2;
 
+function addTypeAndLengthToLen(len: number): number {
+    return len + TYPE_SIZE + LENGTH_SIZE;
+}
+
 // NOTE: All of these should eventually use their type's Span instead of these
 // constants.  This is provided for at least creation to work.
 export function getTypeLen(e: ExtensionType): number {
@@ -79,6 +87,8 @@ export function getTypeLen(e: ExtensionType): number {
             return TRANSFER_HOOK_SIZE;
         case ExtensionType.TransferHookAccount:
             return TRANSFER_HOOK_ACCOUNT_SIZE;
+        case ExtensionType.TokenMetadata:
+            throw Error(`Cannot get type length for variable extension type: ${e}`);
         default:
             throw Error(`Unknown extension type: ${e}`);
     }
@@ -95,6 +105,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 +141,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 +166,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:
@@ -172,7 +185,7 @@ function getLen(extensionTypes: ExtensionType[], baseSize: number): number {
             ACCOUNT_TYPE_SIZE +
             extensionTypes
                 .filter((element, i) => i === extensionTypes.indexOf(element))
-                .map((element) => getTypeLen(element) + TYPE_SIZE + LENGTH_SIZE)
+                .map((element) => addTypeAndLengthToLen(getTypeLen(element)))
                 .reduce((a, b) => a + b);
         if (accountLength === MULTISIG_SIZE) {
             return accountLength + TYPE_SIZE;
@@ -192,10 +205,10 @@ export function getAccountLen(extensionTypes: ExtensionType[]): number {
 
 export function getExtensionData(extension: ExtensionType, tlvData: Buffer): Buffer | null {
     let extensionTypeIndex = 0;
-    while (extensionTypeIndex + TYPE_SIZE + LENGTH_SIZE <= tlvData.length) {
+    while (addTypeAndLengthToLen(extensionTypeIndex) <= tlvData.length) {
         const entryType = tlvData.readUInt16LE(extensionTypeIndex);
         const entryLength = tlvData.readUInt16LE(extensionTypeIndex + TYPE_SIZE);
-        const typeIndex = extensionTypeIndex + TYPE_SIZE + LENGTH_SIZE;
+        const typeIndex = addTypeAndLengthToLen(extensionTypeIndex);
         if (entryType == extension) {
             return tlvData.slice(typeIndex, typeIndex + entryLength);
         }
@@ -211,7 +224,7 @@ export function getExtensionTypes(tlvData: Buffer): ExtensionType[] {
         const entryType = tlvData.readUInt16LE(extensionTypeIndex);
         extensionTypes.push(entryType);
         const entryLength = tlvData.readUInt16LE(extensionTypeIndex + TYPE_SIZE);
-        extensionTypeIndex += TYPE_SIZE + LENGTH_SIZE + entryLength;
+        extensionTypeIndex += addTypeAndLengthToLen(entryLength);
     }
     return extensionTypes;
 }
@@ -221,3 +234,19 @@ export function getAccountLenForMint(mint: Mint): number {
     const accountExtensions = extensionTypes.map(getAccountTypeOfMintType);
     return getAccountLen(accountExtensions);
 }
+
+export function getNewAccountLenForExtensionLen(
+    info: AccountInfo<Buffer>,
+    address: PublicKey,
+    extensionType: ExtensionType,
+    extensionLen: number,
+    programId = TOKEN_2022_PROGRAM_ID
+): number {
+    const mint = unpackMint(address, info, programId);
+    const extensionData = getExtensionData(extensionType, mint.tlvData);
+
+    const currentExtensionLen = extensionData ? addTypeAndLengthToLen(extensionData.length) : 0;
+    const newExtensionLen = addTypeAndLengthToLen(extensionLen);
+
+    return info.data.length + newExtensionLen - currentExtensionLen;
+}
diff --git a/token/js/src/extensions/index.ts b/token/js/src/extensions/index.ts
index 0cb111e6e..725540692 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 000000000..f37868638
--- /dev/null
+++ b/token/js/src/extensions/tokenMetadata/actions.ts
@@ -0,0 +1,382 @@
+import type { ConfirmOptions, Connection, PublicKey, Signer, TransactionSignature } from '@solana/web3.js';
+import { sendAndConfirmTransaction, SystemProgram, Transaction } from '@solana/web3.js';
+import type { Field, TokenMetadata } from '@solana/spl-token-metadata';
+import {
+    createInitializeInstruction,
+    createRemoveKeyInstruction,
+    createUpdateAuthorityInstruction,
+    createUpdateFieldInstruction,
+    pack,
+    unpack,
+} from '@solana/spl-token-metadata';
+
+import { TOKEN_2022_PROGRAM_ID } from '../../constants.js';
+import { getSigners } from '../../actions/internal.js';
+import { ExtensionType, getExtensionData, getNewAccountLenForExtensionLen } from '../extensionType.js';
+import { updateTokenMetadata } from './state.js';
+import { TokenAccountNotFoundError } from '../../errors.js';
+import { unpackMint } from '../../state/index.js';
+
+async function getAdditionalRentForNewMetadata(
+    connection: Connection,
+    address: PublicKey,
+    tokenMetadata: TokenMetadata,
+    programId = TOKEN_2022_PROGRAM_ID
+): Promise<number> {
+    const info = await connection.getAccountInfo(address);
+    if (!info) {
+        throw new TokenAccountNotFoundError();
+    }
+
+    const extensionLen = pack(tokenMetadata).length;
+    const newAccountLen = getNewAccountLenForExtensionLen(
+        info,
+        address,
+        ExtensionType.TokenMetadata,
+        extensionLen,
+        programId
+    );
+
+    if (newAccountLen <= info.data.length) {
+        return 0;
+    }
+
+    const newRentExemptMinimum = await connection.getMinimumBalanceForRentExemption(newAccountLen);
+
+    return newRentExemptMinimum - info.lamports;
+}
+
+async function getAdditionalRentForUpdatedMetadata(
+    connection: Connection,
+    address: PublicKey,
+    field: string | Field,
+    value: string,
+    programId = TOKEN_2022_PROGRAM_ID
+): Promise<number> {
+    const info = await connection.getAccountInfo(address);
+    if (!info) {
+        throw new TokenAccountNotFoundError();
+    }
+
+    const mint = unpackMint(address, info, programId);
+    const extensionData = getExtensionData(ExtensionType.TokenMetadata, mint.tlvData);
+    if (extensionData === null) {
+        throw new Error('TokenMetadata extension not initialized');
+    }
+
+    const updatedTokenMetadata = updateTokenMetadata(unpack(extensionData), field, value);
+    const extensionLen = pack(updatedTokenMetadata).length;
+
+    const newAccountLen = getNewAccountLenForExtensionLen(
+        info,
+        address,
+        ExtensionType.TokenMetadata,
+        extensionLen,
+        programId
+    );
+
+    if (newAccountLen <= info.data.length) {
+        return 0;
+    }
+
+    const newRentExemptMinimum = await connection.getMinimumBalanceForRentExemption(newAccountLen);
+
+    return newRentExemptMinimum - info.lamports;
+}
+
+/**
+ * Initializes a TLV entry with the basic token-metadata fields.
+ *
+ * @param connection       Connection to use
+ * @param payer            Payer of the transaction fees
+ * @param mint             Mint Account
+ * @param updateAuthority  Update Authority
+ * @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,
+    mint: PublicKey,
+    updateAuthority: PublicKey,
+    mintAuthority: PublicKey | Signer,
+    name: string,
+    symbol: string,
+    uri: string,
+    multiSigners: Signer[] = [],
+    confirmOptions?: ConfirmOptions,
+    programId = TOKEN_2022_PROGRAM_ID
+): Promise<TransactionSignature> {
+    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);
+}
+
+/**
+ * Initializes a TLV entry with the basic token-metadata fields,
+ * Includes a transfer for any additional rent-exempt SOL if required.
+ *
+ * @param connection       Connection to use
+ * @param payer            Payer of the transaction fees
+ * @param mint             Mint Account
+ * @param updateAuthority  Update Authority
+ * @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 tokenMetadataInitializeWithRentTransfer(
+    connection: Connection,
+    payer: Signer,
+    mint: PublicKey,
+    updateAuthority: PublicKey,
+    mintAuthority: PublicKey | Signer,
+    name: string,
+    symbol: string,
+    uri: string,
+    multiSigners: Signer[] = [],
+    confirmOptions?: ConfirmOptions,
+    programId = TOKEN_2022_PROGRAM_ID
+): Promise<TransactionSignature> {
+    const [mintAuthorityPublicKey, signers] = getSigners(mintAuthority, multiSigners);
+
+    const transaction = new Transaction();
+
+    const lamports = await getAdditionalRentForNewMetadata(
+        connection,
+        mint,
+        {
+            updateAuthority,
+            mint,
+            name,
+            symbol,
+            uri,
+            additionalMetadata: [],
+        },
+        programId
+    );
+
+    if (lamports > 0) {
+        transaction.add(SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: mint, lamports: lamports }));
+    }
+
+    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 mint             Mint Account
+ * @param updateAuthority  Update Authority
+ * @param field            Field to update in the metadata
+ * @param value            Value to write for the field
+ * @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,
+    mint: PublicKey,
+    updateAuthority: PublicKey | Signer,
+    field: string | Field,
+    value: string,
+    multiSigners: Signer[] = [],
+    confirmOptions?: ConfirmOptions,
+    programId = TOKEN_2022_PROGRAM_ID
+): Promise<TransactionSignature> {
+    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);
+}
+
+/**
+ * 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.
+ * Includes a transfer for any additional rent-exempt SOL if required.
+ *
+ * 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 mint             Mint Account
+ * @param updateAuthority  Update Authority
+ * @param field            Field to update in the metadata
+ * @param value            Value to write for the field
+ * @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 tokenMetadataUpdateFieldWithRentTransfer(
+    connection: Connection,
+    payer: Signer,
+    mint: PublicKey,
+    updateAuthority: PublicKey | Signer,
+    field: string | Field,
+    value: string,
+    multiSigners: Signer[] = [],
+    confirmOptions?: ConfirmOptions,
+    programId = TOKEN_2022_PROGRAM_ID
+): Promise<TransactionSignature> {
+    const [updateAuthorityPublicKey, signers] = getSigners(updateAuthority, multiSigners);
+
+    const transaction = new Transaction();
+
+    const lamports = await getAdditionalRentForUpdatedMetadata(connection, mint, field, value, programId);
+
+    if (lamports > 0) {
+        transaction.add(SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: mint, lamports: lamports }));
+    }
+
+    transaction.add(
+        createUpdateFieldInstruction({
+            programId,
+            metadata: mint,
+            updateAuthority: updateAuthorityPublicKey,
+            field,
+            value,
+        })
+    );
+
+    return await sendAndConfirmTransaction(connection, transaction, [payer, ...signers], confirmOptions);
+}
+
+/**
+ * Remove a field in a token-metadata account.
+ *
+ * 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 mint             Mint Account
+ * @param updateAuthority  Update Authority
+ * @param key              Key to remove in the additional metadata portion
+ * @param idempotent       When true, instruction will not error if the key does not exist
+ * @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 tokenMetadataRemoveKey(
+    connection: Connection,
+    payer: Signer,
+    mint: PublicKey,
+    updateAuthority: PublicKey | Signer,
+    key: string,
+    idempotent: boolean,
+    multiSigners: Signer[] = [],
+    confirmOptions?: ConfirmOptions,
+    programId = TOKEN_2022_PROGRAM_ID
+): Promise<TransactionSignature> {
+    const [updateAuthorityPublicKey, signers] = getSigners(updateAuthority, multiSigners);
+
+    const transaction = new Transaction().add(
+        createRemoveKeyInstruction({
+            programId,
+            metadata: mint,
+            updateAuthority: updateAuthorityPublicKey,
+            key,
+            idempotent,
+        })
+    );
+
+    return await sendAndConfirmTransaction(connection, transaction, [payer, ...signers], confirmOptions);
+}
+
+/**
+ *  Update authority
+ *
+ * @param connection       Connection to use
+ * @param payer            Payer of the transaction fees
+ * @param mint             Mint Account
+ * @param updateAuthority  Update Authority
+ * @param newAuthority     New authority for the token metadata, or unset
+ * @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 tokenMetadataUpdateAuthority(
+    connection: Connection,
+    payer: Signer,
+    mint: PublicKey,
+    updateAuthority: PublicKey | Signer,
+    newAuthority: PublicKey | null,
+    multiSigners: Signer[] = [],
+    confirmOptions?: ConfirmOptions,
+    programId = TOKEN_2022_PROGRAM_ID
+): Promise<TransactionSignature> {
+    const [updateAuthorityPublicKey, signers] = getSigners(updateAuthority, multiSigners);
+
+    const transaction = new Transaction().add(
+        createUpdateAuthorityInstruction({
+            programId,
+            metadata: mint,
+            oldAuthority: updateAuthorityPublicKey,
+            newAuthority,
+        })
+    );
+
+    return await sendAndConfirmTransaction(connection, transaction, [payer, ...signers], confirmOptions);
+}
diff --git a/token/js/src/extensions/tokenMetadata/index.ts b/token/js/src/extensions/tokenMetadata/index.ts
new file mode 100644
index 000000000..898210857
--- /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 000000000..d124dcc75
--- /dev/null
+++ b/token/js/src/extensions/tokenMetadata/state.ts
@@ -0,0 +1,84 @@
+import type { Commitment, Connection } from '@solana/web3.js';
+import type { PublicKey } from '@solana/web3.js';
+import type { TokenMetadata } from '@solana/spl-token-metadata';
+import { Field, 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';
+
+const getNormalizedTokenMetadataField = (field: Field | string): string => {
+    if (field === Field.Name || field === 'Name' || field === 'name') {
+        return 'name';
+    }
+
+    if (field === Field.Symbol || field === 'Symbol' || field === 'symbol') {
+        return 'symbol';
+    }
+
+    if (field === Field.Uri || field === 'Uri' || field === 'uri') {
+        return 'uri';
+    }
+
+    return field;
+};
+
+export function updateTokenMetadata(current: TokenMetadata, key: Field | string, value: string): TokenMetadata {
+    const field = getNormalizedTokenMetadataField(key);
+
+    if (field === 'mint' || field === 'updateAuthority') {
+        throw new Error(`Cannot update ${field} via this instruction`);
+    }
+
+    // Handle updates to default keys
+    if (['name', 'symbol', 'uri'].includes(field)) {
+        return {
+            ...current,
+            [field]: value,
+        };
+    }
+
+    // Avoid mutating input, make a shallow copy
+    const additionalMetadata = [...current.additionalMetadata];
+
+    const i = current.additionalMetadata.findIndex((x) => x[0] === field);
+
+    if (i === -1) {
+        // Key was not found, add it
+        additionalMetadata.push([field, value]);
+    } else {
+        // Key was found, change value
+        additionalMetadata[i] = [field, value];
+    }
+
+    return {
+        ...current,
+        additionalMetadata,
+    };
+}
+
+/**
+ * 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<TokenMetadata | null> {
+    const mintInfo = await getMint(connection, address, commitment, programId);
+    const data = getExtensionData(ExtensionType.TokenMetadata, mintInfo.tlvData);
+
+    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 0a21c050a..b2a18deca 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';


Test patch:
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 000000000..b66c945a2
--- /dev/null
+++ b/token/js/test/e2e-2022/tokenMetadata.test.ts
@@ -0,0 +1,528 @@
+import chai, { expect } from 'chai';
+import chaiAsPromised from 'chai-as-promised';
+
+import { getBase64Encoder } from '@solana/codecs-strings';
+import { createEmitInstruction, pack } from '@solana/spl-token-metadata';
+import {
+    type Connection,
+    sendAndConfirmTransaction,
+    Keypair,
+    type Signer,
+    SystemProgram,
+    Transaction,
+    VersionedTransaction,
+    TransactionMessage,
+} from '@solana/web3.js';
+
+import {
+    ExtensionType,
+    createInitializeMetadataPointerInstruction,
+    createInitializeMintInstruction,
+    getMintLen,
+    getTokenMetadata,
+    tokenMetadataInitialize,
+    tokenMetadataInitializeWithRentTransfer,
+    tokenMetadataRemoveKey,
+    tokenMetadataUpdateAuthority,
+    tokenMetadataUpdateField,
+    tokenMetadataUpdateFieldWithRentTransfer,
+} from '../../src';
+import { TEST_PROGRAM_ID, newAccountWithLamports, getConnection } from '../common';
+
+chai.use(chaiAsPromised);
+
+const TEST_TOKEN_DECIMALS = 2;
+const EXTENSIONS = [ExtensionType.MetadataPointer];
+
+describe('tokenMetadata', async () => {
+    let connection: Connection;
+    let payer: Signer;
+    let mint: Keypair;
+    let mintAuthority: Keypair;
+    let updateAuthority: Keypair;
+
+    before(async () => {
+        connection = await getConnection();
+        payer = await newAccountWithLamports(connection, 1000000000);
+        mintAuthority = Keypair.generate();
+        updateAuthority = Keypair.generate();
+    });
+
+    beforeEach(async () => {
+        mint = Keypair.generate();
+
+        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,
+                programId: TEST_PROGRAM_ID,
+            }),
+            createInitializeMetadataPointerInstruction(
+                mint.publicKey,
+                updateAuthority.publicKey,
+                mint.publicKey,
+                TEST_PROGRAM_ID
+            ),
+            createInitializeMintInstruction(
+                mint.publicKey,
+                TEST_TOKEN_DECIMALS,
+                mintAuthority.publicKey,
+                null,
+                TEST_PROGRAM_ID
+            )
+        );
+
+        await sendAndConfirmTransaction(connection, transaction, [payer, mint], undefined);
+    });
+
+    it('can fetch un-initialized token metadata as null', async () => {
+        expect(await getTokenMetadata(connection, mint.publicKey, undefined, TEST_PROGRAM_ID)).to.deep.equal(null);
+    });
+
+    it('can initialize', async () => {
+        const tokenMetadata = {
+            updateAuthority: updateAuthority.publicKey,
+            mint: mint.publicKey,
+            name: 'name',
+            symbol: 'symbol',
+            uri: 'uri',
+            additionalMetadata: [],
+        };
+
+        // Transfer the required amount for rent exemption
+        const lamports = await connection.getMinimumBalanceForRentExemption(pack(tokenMetadata).length);
+        const transaction = new Transaction().add(
+            SystemProgram.transfer({
+                fromPubkey: payer.publicKey,
+                toPubkey: mint.publicKey,
+                lamports,
+            })
+        );
+        await sendAndConfirmTransaction(connection, transaction, [payer], undefined);
+
+        await tokenMetadataInitialize(
+            connection,
+            payer,
+            mint.publicKey,
+            tokenMetadata.updateAuthority,
+            mintAuthority,
+            tokenMetadata.name,
+            tokenMetadata.symbol,
+            tokenMetadata.uri,
+            [mintAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+
+        const meta = await getTokenMetadata(connection, mint.publicKey, undefined, TEST_PROGRAM_ID);
+        expect(meta).to.deep.equal({
+            updateAuthority: updateAuthority.publicKey,
+            mint: mint.publicKey,
+            name: 'name',
+            symbol: 'symbol',
+            uri: 'uri',
+            additionalMetadata: [],
+        });
+    });
+
+    it('can initialize with rent transfer', async () => {
+        await tokenMetadataInitializeWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            mintAuthority,
+            'name',
+            'symbol',
+            'uri',
+            [mintAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        const meta = await getTokenMetadata(connection, mint.publicKey, undefined, TEST_PROGRAM_ID);
+        expect(meta).to.deep.equal({
+            updateAuthority: updateAuthority.publicKey,
+            mint: mint.publicKey,
+            name: 'name',
+            symbol: 'symbol',
+            uri: 'uri',
+            additionalMetadata: [],
+        });
+    });
+
+    it('can update an existing default field', async () => {
+        await tokenMetadataInitializeWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            mintAuthority,
+            'name',
+            'symbol',
+            'uri',
+            [mintAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        await tokenMetadataUpdateField(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            'name',
+            'TEST',
+            [updateAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        const meta = await getTokenMetadata(connection, mint.publicKey, undefined, TEST_PROGRAM_ID);
+        expect(meta).to.deep.equal({
+            updateAuthority: updateAuthority.publicKey,
+            mint: mint.publicKey,
+            name: 'TEST',
+            symbol: 'symbol',
+            uri: 'uri',
+            additionalMetadata: [],
+        });
+    });
+
+    it('can update an existing default field with rent transfer', async () => {
+        await tokenMetadataInitializeWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            mintAuthority,
+            'name',
+            'symbol',
+            'uri',
+            [mintAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        await tokenMetadataUpdateFieldWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            'name',
+            'My Shiny New Token Metadata',
+            [updateAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        const meta = await getTokenMetadata(connection, mint.publicKey, undefined, TEST_PROGRAM_ID);
+        expect(meta).to.deep.equal({
+            updateAuthority: updateAuthority.publicKey,
+            mint: mint.publicKey,
+            name: 'My Shiny New Token Metadata',
+            symbol: 'symbol',
+            uri: 'uri',
+            additionalMetadata: [],
+        });
+    });
+
+    it('can create a custom field with rent transfer', async () => {
+        await tokenMetadataInitializeWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            mintAuthority,
+            'name',
+            'symbol',
+            'uri',
+            [mintAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        await tokenMetadataUpdateFieldWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            'myCustomField',
+            'CUSTOM',
+            [updateAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        const meta = await getTokenMetadata(connection, mint.publicKey, undefined, TEST_PROGRAM_ID);
+        expect(meta).to.deep.equal({
+            updateAuthority: updateAuthority.publicKey,
+            mint: mint.publicKey,
+            name: 'name',
+            symbol: 'symbol',
+            uri: 'uri',
+            additionalMetadata: [['myCustomField', 'CUSTOM']],
+        });
+    });
+
+    it('can update a custom field', async () => {
+        await tokenMetadataInitializeWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            mintAuthority,
+            'name',
+            'symbol',
+            'uri',
+            [mintAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        await tokenMetadataUpdateFieldWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            'myCustomField',
+            'CUSTOM',
+            [updateAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        await tokenMetadataUpdateField(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            'myCustomField',
+            'test',
+            [updateAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        const meta = await getTokenMetadata(connection, mint.publicKey, undefined, TEST_PROGRAM_ID);
+        expect(meta).to.deep.equal({
+            updateAuthority: updateAuthority.publicKey,
+            mint: mint.publicKey,
+            name: 'name',
+            symbol: 'symbol',
+            uri: 'uri',
+            additionalMetadata: [['myCustomField', 'test']],
+        });
+    });
+
+    it('can update a custom field with rent transfer', async () => {
+        await tokenMetadataInitializeWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            mintAuthority,
+            'name',
+            'symbol',
+            'uri',
+            [mintAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        await tokenMetadataUpdateFieldWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            'myCustomField',
+            'CUSTOM',
+            [updateAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        await tokenMetadataUpdateFieldWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            'myCustomField',
+            'My Shiny Custom Field',
+            [updateAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        const meta = await getTokenMetadata(connection, mint.publicKey, undefined, TEST_PROGRAM_ID);
+        expect(meta).to.deep.equal({
+            updateAuthority: updateAuthority.publicKey,
+            mint: mint.publicKey,
+            name: 'name',
+            symbol: 'symbol',
+            uri: 'uri',
+            additionalMetadata: [['myCustomField', 'My Shiny Custom Field']],
+        });
+    });
+
+    it('can remove a custom field', async () => {
+        await tokenMetadataInitializeWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            mintAuthority,
+            'name',
+            'symbol',
+            'uri',
+            [mintAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        await tokenMetadataUpdateFieldWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            'myCustomField',
+            'CUSTOM',
+            [updateAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        await tokenMetadataRemoveKey(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            'myCustomField',
+            true,
+            [updateAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        const meta = await getTokenMetadata(connection, mint.publicKey, undefined, TEST_PROGRAM_ID);
+        expect(meta).to.deep.equal({
+            updateAuthority: updateAuthority.publicKey,
+            mint: mint.publicKey,
+            name: 'name',
+            symbol: 'symbol',
+            uri: 'uri',
+            additionalMetadata: [],
+        });
+    });
+
+    it('can handle removal of a key that does not exist when idempotent is true', async () => {
+        await tokenMetadataInitializeWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            mintAuthority,
+            'name',
+            'symbol',
+            'uri',
+            [mintAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        await tokenMetadataRemoveKey(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            'myCustomField',
+            true,
+            [updateAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        const meta = await getTokenMetadata(connection, mint.publicKey, undefined, TEST_PROGRAM_ID);
+        expect(meta).to.deep.equal({
+            updateAuthority: updateAuthority.publicKey,
+            mint: mint.publicKey,
+            name: 'name',
+            symbol: 'symbol',
+            uri: 'uri',
+            additionalMetadata: [],
+        });
+    });
+
+    it('can update the authority', async () => {
+        await tokenMetadataInitializeWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            mintAuthority,
+            'name',
+            'symbol',
+            'uri',
+            [mintAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        const newAuthority = Keypair.generate().publicKey;
+        await tokenMetadataUpdateAuthority(
+            connection,
+            payer,
+            mint.publicKey,
+            updateAuthority.publicKey,
+            newAuthority,
+            [updateAuthority],
+            undefined,
+            TEST_PROGRAM_ID
+        );
+        const meta = await getTokenMetadata(connection, mint.publicKey, undefined, TEST_PROGRAM_ID);
+        expect(meta).to.deep.equal({
+            updateAuthority: newAuthority,
+            mint: mint.publicKey,
+            name: 'name',
+            symbol: 'symbol',
+            uri: 'uri',
+            additionalMetadata: [],
+        });
+    });
+
+    it('can emit part of a token metadata', async () => {
+        const tokenMetadata = {
+            updateAuthority: updateAuthority.publicKey,
+            mint: mint.publicKey,
+            name: 'name',
+            symbol: 'symbol',
+            uri: 'uri',
+            additionalMetadata: [],
+        };
+
+        await tokenMetadataInitializeWithRentTransfer(
+            connection,
+            payer,
+            mint.publicKey,
+            tokenMetadata.updateAuthority,
+            mintAuthority,
+            tokenMetadata.name,
+            tokenMetadata.symbol,
+            tokenMetadata.uri,
+            undefined,
+            undefined,
+            TEST_PROGRAM_ID
+        );
+
+        const payerKey = payer.publicKey;
+        const recentBlockhash = await connection.getLatestBlockhash().then((res) => res.blockhash);
+        const instructions = [
+            createEmitInstruction({
+                programId: TEST_PROGRAM_ID,
+                metadata: mint.publicKey,
+                start: 0n,
+                end: 32n,
+            }),
+        ];
+        const messageV0 = new TransactionMessage({
+            payerKey,
+            recentBlockhash,
+            instructions,
+        }).compileToV0Message();
+        const tx = new VersionedTransaction(messageV0);
+        tx.sign([payer]);
+
+        const returnDataBase64 = (await connection
+            .simulateTransaction(tx)
+            .then((res) => res.value.returnData?.data[0])) as string;
+        const returnData = getBase64Encoder().encode(returnDataBase64);
+
+        expect(returnData).to.deep.equal(tokenMetadata.updateAuthority.toBuffer());
+    });
+});
diff --git a/token/js/test/unit/tokenMetadata.test.ts b/token/js/test/unit/tokenMetadata.test.ts
new file mode 100644
index 000000000..33bbb1a81
--- /dev/null
+++ b/token/js/test/unit/tokenMetadata.test.ts
@@ -0,0 +1,221 @@
+import { PublicKey } from '@solana/web3.js';
+import { expect } from 'chai';
+
+import type { TokenMetadata } from '@solana/spl-token-metadata';
+import { Field } from '@solana/spl-token-metadata';
+import { updateTokenMetadata } from '../../src';
+
+describe('SPL Token 2022 Metadata Extension', () => {
+    describe('Update token metadata', () => {
+        it('guards against updates on mint or updateAuthority', async () => {
+            const input = Object.freeze({
+                mint: PublicKey.default,
+                name: 'new_name',
+                symbol: 'new_symbol',
+                uri: 'new_uri',
+                additionalMetadata: [
+                    ['key1', 'value1'],
+                    ['key2', 'value2'],
+                ],
+            } as TokenMetadata);
+
+            expect(() => updateTokenMetadata(input, 'mint', 'string')).to.throw(
+                'Cannot update mint via this instruction'
+            );
+            expect(() => updateTokenMetadata(input, 'updateAuthority', 'string')).to.throw(
+                'Cannot update updateAuthority via this instruction'
+            );
+        });
+        it('can update name', async () => {
+            const input = Object.freeze({
+                mint: PublicKey.default,
+                name: 'new_name',
+                symbol: 'new_symbol',
+                uri: 'new_uri',
+                additionalMetadata: [
+                    ['key1', 'value1'],
+                    ['key2', 'value2'],
+                ],
+            } as TokenMetadata);
+
+            const expected: TokenMetadata = {
+                mint: PublicKey.default,
+                name: 'updated_name',
+                symbol: 'new_symbol',
+                uri: 'new_uri',
+                additionalMetadata: [
+                    ['key1', 'value1'],
+                    ['key2', 'value2'],
+                ],
+            };
+
+            expect(updateTokenMetadata(input, 'name', 'updated_name')).to.deep.equal(expected);
+            expect(updateTokenMetadata(input, 'Name', 'updated_name')).to.deep.equal(expected);
+            expect(updateTokenMetadata(input, Field.Name, 'updated_name')).to.deep.equal(expected);
+        });
+
+        it('can update symbol', async () => {
+            const input = Object.freeze({
+                mint: PublicKey.default,
+                name: 'new_name',
+                symbol: 'new_symbol',
+                uri: 'new_uri',
+                additionalMetadata: [
+                    ['key1', 'value1'],
+                    ['key2', 'value2'],
+                ],
+            } as TokenMetadata);
+
+            const expected: TokenMetadata = {
+                mint: PublicKey.default,
+                name: 'new_name',
+                symbol: 'updated_symbol',
+                uri: 'new_uri',
+                additionalMetadata: [
+                    ['key1', 'value1'],
+                    ['key2', 'value2'],
+                ],
+            };
+
+            expect(updateTokenMetadata(input, 'symbol', 'updated_symbol')).to.deep.equal(expected);
+            expect(updateTokenMetadata(input, 'Symbol', 'updated_symbol')).to.deep.equal(expected);
+            expect(updateTokenMetadata(input, Field.Symbol, 'updated_symbol')).to.deep.equal(expected);
+        });
+
+        it('can update uri', async () => {
+            const input = Object.freeze({
+                mint: PublicKey.default,
+                name: 'new_name',
+                symbol: 'new_symbol',
+                uri: 'new_uri',
+                additionalMetadata: [
+                    ['key1', 'value1'],
+                    ['key2', 'value2'],
+                ],
+            } as TokenMetadata);
+
+            const expected: TokenMetadata = {
+                mint: PublicKey.default,
+                name: 'new_name',
+                symbol: 'new_symbol',
+                uri: 'updated_uri',
+                additionalMetadata: [
+                    ['key1', 'value1'],
+                    ['key2', 'value2'],
+                ],
+            };
+
+            expect(updateTokenMetadata(input, 'uri', 'updated_uri')).to.deep.equal(expected);
+            expect(updateTokenMetadata(input, 'Uri', 'updated_uri')).to.deep.equal(expected);
+            expect(updateTokenMetadata(input, Field.Uri, 'updated_uri')).to.deep.equal(expected);
+        });
+
+        it('can update additional Metadata', async () => {
+            const input = Object.freeze({
+                mint: PublicKey.default,
+                name: 'new_name',
+                symbol: 'new_symbol',
+                uri: 'new_uri',
+                additionalMetadata: [
+                    ['key1', 'value1'],
+                    ['key2', 'value2'],
+                ],
+            } as TokenMetadata);
+
+            const expected: TokenMetadata = {
+                mint: PublicKey.default,
+                name: 'new_name',
+                symbol: 'new_symbol',
+                uri: 'new_uri',
+                additionalMetadata: [
+                    ['key1', 'update1'],
+                    ['key2', 'value2'],
+                ],
+            };
+
+            expect(updateTokenMetadata(input, 'key1', 'update1')).to.deep.equal(expected);
+        });
+
+        it('can add additional Metadata', async () => {
+            const input = Object.freeze({
+                mint: PublicKey.default,
+                name: 'new_name',
+                symbol: 'new_symbol',
+                uri: 'new_uri',
+                additionalMetadata: [
+                    ['key1', 'value1'],
+                    ['key2', 'value2'],
+                ],
+            } as TokenMetadata);
+
+            const expected: TokenMetadata = {
+                mint: PublicKey.default,
+                name: 'new_name',
+                symbol: 'new_symbol',
+                uri: 'new_uri',
+                additionalMetadata: [
+                    ['key1', 'value1'],
+                    ['key2', 'value2'],
+                    ['key3', 'value3'],
+                ],
+            };
+
+            expect(updateTokenMetadata(input, 'key3', 'value3')).to.deep.equal(expected);
+        });
+
+        it('can update `additionalMetadata` key to additional metadata', async () => {
+            const input = Object.freeze({
+                mint: PublicKey.default,
+                name: 'new_name',
+                symbol: 'new_symbol',
+                uri: 'new_uri',
+                additionalMetadata: [
+                    ['key1', 'value1'],
+                    ['key2', 'value2'],
+                    ['additionalMetadata', 'value3'],
+                ],
+            } as TokenMetadata);
+
+            const expected: TokenMetadata = {
+                mint: PublicKey.default,
+                name: 'new_name',
+                symbol: 'new_symbol',
+                uri: 'new_uri',
+                additionalMetadata: [
+                    ['key1', 'value1'],
+                    ['key2', 'value2'],
+                    ['additionalMetadata', 'update3'],
+                ],
+            };
+
+            expect(updateTokenMetadata(input, 'additionalMetadata', 'update3')).to.deep.equal(expected);
+        });
+
+        it('can add `additionalMetadata` key to additional metadata', async () => {
+            const input = Object.freeze({
+                mint: PublicKey.default,
+                name: 'new_name',
+                symbol: 'new_symbol',
+                uri: 'new_uri',
+                additionalMetadata: [
+                    ['key1', 'value1'],
+                    ['key2', 'value2'],
+                ],
+            } as TokenMetadata);
+
+            const expected: TokenMetadata = {
+                mint: PublicKey.default,
+                name: 'new_name',
+                symbol: 'new_symbol',
+                uri: 'new_uri',
+                additionalMetadata: [
+                    ['key1', 'value1'],
+                    ['key2', 'value2'],
+                    ['additionalMetadata', 'value3'],
+                ],
+            };
+
+            expect(updateTokenMetadata(input, 'additionalMetadata', 'value3')).to.deep.equal(expected);
+        });
+    });
+});