diff --git a/clients/js-legacy/.eslintignore b/clients/js-legacy/.eslintignore new file mode 100644 index 0000000..6da325e --- /dev/null +++ b/clients/js-legacy/.eslintignore @@ -0,0 +1,5 @@ +docs +lib +test-ledger + +package-lock.json diff --git a/clients/js-legacy/.eslintrc b/clients/js-legacy/.eslintrc new file mode 100644 index 0000000..5aef10a --- /dev/null +++ b/clients/js-legacy/.eslintrc @@ -0,0 +1,34 @@ +{ + "root": true, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended", + "plugin:require-extensions/recommended" + ], + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint", + "prettier", + "require-extensions" + ], + "rules": { + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/consistent-type-imports": "error" + }, + "overrides": [ + { + "files": [ + "examples/**/*", + "test/**/*" + ], + "rules": { + "require-extensions/require-extensions": "off", + "require-extensions/require-index": "off" + } + } + ] +} diff --git a/clients/js-legacy/.gitignore b/clients/js-legacy/.gitignore new file mode 100644 index 0000000..21f33db --- /dev/null +++ b/clients/js-legacy/.gitignore @@ -0,0 +1,13 @@ +.idea +.vscode +.DS_Store + +node_modules + +pnpm-lock.yaml +yarn.lock + +docs +lib +test-ledger +*.tsbuildinfo diff --git a/clients/js-legacy/.mocharc.json b/clients/js-legacy/.mocharc.json new file mode 100644 index 0000000..451c14c --- /dev/null +++ b/clients/js-legacy/.mocharc.json @@ -0,0 +1,5 @@ +{ + "extension": ["ts"], + "node-option": ["experimental-specifier-resolution=node", "loader=ts-node/esm"], + "timeout": 5000 +} diff --git a/clients/js-legacy/.nojekyll b/clients/js-legacy/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/clients/js-legacy/LICENSE b/clients/js-legacy/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/clients/js-legacy/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/clients/js-legacy/README.md b/clients/js-legacy/README.md new file mode 100644 index 0000000..805d801 --- /dev/null +++ b/clients/js-legacy/README.md @@ -0,0 +1,61 @@ +# `@solana/spl-token-metadata` + +A TypeScript interface describing the instructions required for a program to implement to be considered a "token-metadata" program for SPL token mints. The interface can be implemented by any program. + +## Links + +- [TypeScript Docs](https://solana-labs.github.io/solana-program-library/token-metadata/js/) +- [FAQs (Frequently Asked Questions)](#faqs) +- [Install](#install) +- [Build from Source](#build-from-source) + +## FAQs + +### How can I get support? + +Please ask questions in the Solana Stack Exchange: https://solana.stackexchange.com/ + +If you've found a bug or you'd like to request a feature, please +[open an issue](https://github.com/solana-labs/solana-program-library/issues/new). + +## Install + +```shell +npm install --save @solana/spl-token-metadata @solana/web3.js@1 +``` +_OR_ +```shell +yarn add @solana/spl-token-metadata @solana/web3.js@1 +``` + +## Build from Source + +0. Prerequisites + +* Node 16+ +* NPM 8+ + +1. Clone the project: +```shell +git clone https://github.com/solana-labs/solana-program-library.git +``` + +2. Navigate to the library: +```shell +cd solana-program-library/token-metadata/js +``` + +3. Install the dependencies: +```shell +npm install +``` + +4. Build the library: +```shell +npm run build +``` + +5. Build the on-chain programs: +```shell +npm run test:build-programs +``` diff --git a/clients/js-legacy/package.json b/clients/js-legacy/package.json new file mode 100644 index 0000000..8d122b5 --- /dev/null +++ b/clients/js-legacy/package.json @@ -0,0 +1,70 @@ +{ + "name": "@solana/spl-token-metadata", + "description": "SPL Token Metadata Interface JS API", + "version": "0.1.6", + "author": "Solana Labs Maintainers ", + "repository": "https://github.com/solana-labs/solana-program-library", + "license": "Apache-2.0", + "type": "module", + "sideEffects": false, + "engines": { + "node": ">=16" + }, + "files": [ + "lib", + "src", + "LICENSE", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "main": "./lib/cjs/index.js", + "module": "./lib/esm/index.js", + "types": "./lib/types/index.d.ts", + "exports": { + "types": "./lib/types/index.d.ts", + "require": "./lib/cjs/index.js", + "import": "./lib/esm/index.js" + }, + "scripts": { + "build": "tsc --build --verbose tsconfig.all.json", + "clean": "shx rm -rf lib **/*.tsbuildinfo || true", + "deploy": "npm run deploy:docs", + "deploy:docs": "npm run docs && gh-pages --dest token-metadata/js --dist docs --dotfiles", + "docs": "shx rm -rf docs && typedoc && shx cp .nojekyll docs/", + "lint": "eslint --max-warnings 0 .", + "lint:fix": "eslint --fix .", + "nuke": "shx rm -rf node_modules package-lock.json || true", + "postbuild": "shx echo '{ \"type\": \"commonjs\" }' > lib/cjs/package.json", + "reinstall": "npm run nuke && npm install", + "release": "npm run clean && npm run build", + "test": "mocha test", + "watch": "tsc --build --verbose --watch tsconfig.all.json" + }, + "peerDependencies": { + "@solana/web3.js": "^1.95.5" + }, + "dependencies": { + "@solana/codecs": "2.0.0" + }, + "devDependencies": { + "@solana/spl-type-length-value": "0.2.0", + "@solana/web3.js": "^1.95.5", + "@types/chai": "^5.0.1", + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.2", + "@typescript-eslint/eslint-plugin": "^8.4.0", + "@typescript-eslint/parser": "^8.4.0", + "chai": "^5.1.2", + "eslint": "^8.57.0", + "eslint-plugin-require-extensions": "^0.1.1", + "gh-pages": "^6.2.0", + "mocha": "^11.0.1", + "shx": "^0.3.4", + "ts-node": "^10.9.2", + "tslib": "^2.8.1", + "typedoc": "^0.27.4", + "typescript": "^5.7.2" + } +} diff --git a/clients/js-legacy/src/errors.ts b/clients/js-legacy/src/errors.ts new file mode 100644 index 0000000..b86785b --- /dev/null +++ b/clients/js-legacy/src/errors.ts @@ -0,0 +1,39 @@ +// Errors match those in rust https://github.com/solana-labs/solana-program-library/blob/master/token-metadata/interface/src/error.rs +// Code follows: https://github.com/solana-labs/solana-program-library/blob/master/token/js/src/errors.tshttps://github.com/solana-labs/solana-program-library/blob/master/token/js/src/errors.ts + +/** Base class for errors */ +export class TokenMetadataError extends Error { + constructor(message?: string) { + super(message); + } +} + +/** Thrown if incorrect account provided */ +export class IncorrectAccountError extends TokenMetadataError { + name = 'IncorrectAccountError'; +} + +/** Thrown if Mint has no mint authority */ +export class MintHasNoMintAuthorityError extends TokenMetadataError { + name = 'MintHasNoMintAuthorityError'; +} + +/** Thrown if Incorrect mint authority has signed the instruction */ +export class IncorrectMintAuthorityError extends TokenMetadataError { + name = 'IncorrectMintAuthorityError'; +} + +/** Thrown if Incorrect mint authority has signed the instruction */ +export class IncorrectUpdateAuthorityError extends TokenMetadataError { + name = 'IncorrectUpdateAuthorityError'; +} + +/** Thrown if Token metadata has no update authority */ +export class ImmutableMetadataError extends TokenMetadataError { + name = 'ImmutableMetadataError'; +} + +/** Thrown if Key not found in metadata account */ +export class KeyNotFoundError extends TokenMetadataError { + name = 'KeyNotFoundError'; +} diff --git a/clients/js-legacy/src/field.ts b/clients/js-legacy/src/field.ts new file mode 100644 index 0000000..58db194 --- /dev/null +++ b/clients/js-legacy/src/field.ts @@ -0,0 +1,37 @@ +import type { Codec } from '@solana/codecs'; +import { + addCodecSizePrefix, + getU32Codec, + getUtf8Codec, + getStructCodec, + getTupleCodec, + getUnitCodec, +} from '@solana/codecs'; + +export enum Field { + Name, + Symbol, + Uri, +} + +type FieldLayout = { __kind: 'Name' } | { __kind: 'Symbol' } | { __kind: 'Uri' } | { __kind: 'Key'; value: [string] }; + +export const getFieldCodec = () => + [ + ['Name', getUnitCodec()], + ['Symbol', getUnitCodec()], + ['Uri', getUnitCodec()], + ['Key', getStructCodec([['value', getTupleCodec([addCodecSizePrefix(getUtf8Codec(), getU32Codec())])]])], + ] as const; + +export function getFieldConfig(field: Field | string): FieldLayout { + if (field === Field.Name || field === 'Name' || field === 'name') { + return { __kind: 'Name' }; + } else if (field === Field.Symbol || field === 'Symbol' || field === 'symbol') { + return { __kind: 'Symbol' }; + } else if (field === Field.Uri || field === 'Uri' || field === 'uri') { + return { __kind: 'Uri' }; + } else { + return { __kind: 'Key', value: [field] }; + } +} diff --git a/clients/js-legacy/src/index.ts b/clients/js-legacy/src/index.ts new file mode 100644 index 0000000..faffcde --- /dev/null +++ b/clients/js-legacy/src/index.ts @@ -0,0 +1,4 @@ +export * from './errors.js'; +export * from './field.js'; +export * from './instruction.js'; +export * from './state.js'; diff --git a/clients/js-legacy/src/instruction.ts b/clients/js-legacy/src/instruction.ts new file mode 100644 index 0000000..44fc4a3 --- /dev/null +++ b/clients/js-legacy/src/instruction.ts @@ -0,0 +1,201 @@ +import type { Encoder } from '@solana/codecs'; +import { + addEncoderSizePrefix, + fixEncoderSize, + getBooleanEncoder, + getBytesEncoder, + getDataEnumCodec, + getOptionEncoder, + getUtf8Encoder, + getStructEncoder, + getTupleEncoder, + getU32Encoder, + getU64Encoder, + transformEncoder, +} from '@solana/codecs'; +import type { VariableSizeEncoder } from '@solana/codecs'; +import type { PublicKey } from '@solana/web3.js'; +import { SystemProgram, TransactionInstruction } from '@solana/web3.js'; + +import type { Field } from './field.js'; +import { getFieldCodec, getFieldConfig } from './field.js'; + +function getInstructionEncoder(discriminator: Uint8Array, dataEncoder: Encoder): Encoder { + return transformEncoder(getTupleEncoder([getBytesEncoder(), dataEncoder]), (data: T): [Uint8Array, T] => [ + discriminator, + data, + ]); +} + +function getPublicKeyEncoder(): Encoder { + return transformEncoder(fixEncoderSize(getBytesEncoder(), 32), (publicKey: PublicKey) => publicKey.toBytes()); +} + +function getStringEncoder(): VariableSizeEncoder { + return addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder()); +} + +/** + * Initializes a TLV entry with the basic token-metadata fields. + * + * Assumes that the provided mint is an SPL token mint, that the metadata + * account is allocated and assigned to the program, and that the metadata + * account has enough lamports to cover the rent-exempt reserve. + */ +export interface InitializeInstructionArgs { + programId: PublicKey; + metadata: PublicKey; + updateAuthority: PublicKey; + mint: PublicKey; + mintAuthority: PublicKey; + name: string; + symbol: string; + uri: string; +} + +export function createInitializeInstruction(args: InitializeInstructionArgs): TransactionInstruction { + const { programId, metadata, updateAuthority, mint, mintAuthority, name, symbol, uri } = args; + return new TransactionInstruction({ + programId, + keys: [ + { isSigner: false, isWritable: true, pubkey: metadata }, + { isSigner: false, isWritable: false, pubkey: updateAuthority }, + { isSigner: false, isWritable: false, pubkey: mint }, + { isSigner: true, isWritable: false, pubkey: mintAuthority }, + ], + data: Buffer.from( + getInstructionEncoder( + new Uint8Array([ + /* await splDiscriminate('spl_token_metadata_interface:initialize_account') */ + 210, 225, 30, 162, 88, 184, 77, 141, + ]), + getStructEncoder([ + ['name', getStringEncoder()], + ['symbol', getStringEncoder()], + ['uri', getStringEncoder()], + ]), + ).encode({ name, symbol, uri }), + ), + }); +} + +/** + * If the field does not exist on the account, it will be created. + * If the field does exist, it will be overwritten. + */ +export interface UpdateFieldInstruction { + programId: PublicKey; + metadata: PublicKey; + updateAuthority: PublicKey; + field: Field | string; + value: string; +} + +export function createUpdateFieldInstruction(args: UpdateFieldInstruction): TransactionInstruction { + const { programId, metadata, updateAuthority, field, value } = args; + return new TransactionInstruction({ + programId, + keys: [ + { isSigner: false, isWritable: true, pubkey: metadata }, + { isSigner: true, isWritable: false, pubkey: updateAuthority }, + ], + data: Buffer.from( + getInstructionEncoder( + new Uint8Array([ + /* await splDiscriminate('spl_token_metadata_interface:updating_field') */ + 221, 233, 49, 45, 181, 202, 220, 200, + ]), + getStructEncoder([ + ['field', getDataEnumCodec(getFieldCodec())], + ['value', getStringEncoder()], + ]), + ).encode({ field: getFieldConfig(field), value }), + ), + }); +} + +export interface RemoveKeyInstructionArgs { + programId: PublicKey; + metadata: PublicKey; + updateAuthority: PublicKey; + key: string; + idempotent: boolean; +} + +export function createRemoveKeyInstruction(args: RemoveKeyInstructionArgs) { + const { programId, metadata, updateAuthority, key, idempotent } = args; + return new TransactionInstruction({ + programId, + keys: [ + { isSigner: false, isWritable: true, pubkey: metadata }, + { isSigner: true, isWritable: false, pubkey: updateAuthority }, + ], + data: Buffer.from( + getInstructionEncoder( + new Uint8Array([ + /* await splDiscriminate('spl_token_metadata_interface:remove_key_ix') */ + 234, 18, 32, 56, 89, 141, 37, 181, + ]), + getStructEncoder([ + ['idempotent', getBooleanEncoder()], + ['key', getStringEncoder()], + ]), + ).encode({ idempotent, key }), + ), + }); +} + +export interface UpdateAuthorityInstructionArgs { + programId: PublicKey; + metadata: PublicKey; + oldAuthority: PublicKey; + newAuthority: PublicKey | null; +} + +export function createUpdateAuthorityInstruction(args: UpdateAuthorityInstructionArgs): TransactionInstruction { + const { programId, metadata, oldAuthority, newAuthority } = args; + + return new TransactionInstruction({ + programId, + keys: [ + { isSigner: false, isWritable: true, pubkey: metadata }, + { isSigner: true, isWritable: false, pubkey: oldAuthority }, + ], + data: Buffer.from( + getInstructionEncoder( + new Uint8Array([ + /* await splDiscriminate('spl_token_metadata_interface:update_the_authority') */ + 215, 228, 166, 228, 84, 100, 86, 123, + ]), + getStructEncoder([['newAuthority', getPublicKeyEncoder()]]), + ).encode({ newAuthority: newAuthority ?? SystemProgram.programId }), + ), + }); +} + +export interface EmitInstructionArgs { + programId: PublicKey; + metadata: PublicKey; + start?: bigint; + end?: bigint; +} + +export function createEmitInstruction(args: EmitInstructionArgs): TransactionInstruction { + const { programId, metadata, start, end } = args; + return new TransactionInstruction({ + programId, + keys: [{ isSigner: false, isWritable: false, pubkey: metadata }], + data: Buffer.from( + getInstructionEncoder( + new Uint8Array([ + /* await splDiscriminate('spl_token_metadata_interface:emitter') */ + 250, 166, 180, 250, 13, 12, 184, 70, + ]), + getStructEncoder([ + ['start', getOptionEncoder(getU64Encoder())], + ['end', getOptionEncoder(getU64Encoder())], + ]), + ).encode({ start: start ?? null, end: end ?? null }), + ), + }); +} diff --git a/clients/js-legacy/src/state.ts b/clients/js-legacy/src/state.ts new file mode 100644 index 0000000..6f689eb --- /dev/null +++ b/clients/js-legacy/src/state.ts @@ -0,0 +1,85 @@ +import { PublicKey } from '@solana/web3.js'; +import { + addCodecSizePrefix, + fixCodecSize, + getArrayCodec, + getBytesCodec, + getUtf8Codec, + getU32Codec, + getStructCodec, + getTupleCodec, +} from '@solana/codecs'; +import type { ReadonlyUint8Array, VariableSizeCodec } from '@solana/codecs'; + +export const TOKEN_METADATA_DISCRIMINATOR = Buffer.from([112, 132, 90, 90, 11, 88, 157, 87]); + +function getStringCodec(): VariableSizeCodec { + return addCodecSizePrefix(getUtf8Codec(), getU32Codec()); +} + +const tokenMetadataCodec = getStructCodec([ + ['updateAuthority', fixCodecSize(getBytesCodec(), 32)], + ['mint', fixCodecSize(getBytesCodec(), 32)], + ['name', getStringCodec()], + ['symbol', getStringCodec()], + ['uri', getStringCodec()], + ['additionalMetadata', getArrayCodec(getTupleCodec([getStringCodec(), getStringCodec()]))], +]); + +export interface TokenMetadata { + // The authority that can sign to update the metadata + updateAuthority?: PublicKey; + // The associated mint, used to counter spoofing to be sure that metadata belongs to a particular mint + mint: PublicKey; + // The longer name of the token + name: string; + // The shortened symbol for the token + symbol: string; + // The URI pointing to richer metadata + uri: string; + // Any additional metadata about the token as key-value pairs + additionalMetadata: (readonly [string, string])[]; +} + +// Checks if all elements in the array are 0 +function isNonePubkey(buffer: ReadonlyUint8Array): boolean { + for (let i = 0; i < buffer.length; i++) { + if (buffer[i] !== 0) { + return false; + } + } + return true; +} + +// Pack TokenMetadata into byte slab +export function pack(meta: TokenMetadata): ReadonlyUint8Array { + // If no updateAuthority given, set it to the None/Zero PublicKey for encoding + const updateAuthority = meta.updateAuthority ?? PublicKey.default; + return tokenMetadataCodec.encode({ + ...meta, + updateAuthority: updateAuthority.toBuffer(), + mint: meta.mint.toBuffer(), + }); +} + +// unpack byte slab into TokenMetadata +export function unpack(buffer: Buffer | Uint8Array | ReadonlyUint8Array): TokenMetadata { + const data = tokenMetadataCodec.decode(buffer); + + return isNonePubkey(data.updateAuthority) + ? { + mint: new PublicKey(data.mint), + name: data.name, + symbol: data.symbol, + uri: data.uri, + additionalMetadata: data.additionalMetadata, + } + : { + updateAuthority: new PublicKey(data.updateAuthority), + mint: new PublicKey(data.mint), + name: data.name, + symbol: data.symbol, + uri: data.uri, + additionalMetadata: data.additionalMetadata, + }; +} diff --git a/clients/js-legacy/test/instruction.test.ts b/clients/js-legacy/test/instruction.test.ts new file mode 100644 index 0000000..2641238 --- /dev/null +++ b/clients/js-legacy/test/instruction.test.ts @@ -0,0 +1,167 @@ +import { expect } from 'chai'; + +import { + createEmitInstruction, + createInitializeInstruction, + createRemoveKeyInstruction, + createUpdateAuthorityInstruction, + createUpdateFieldInstruction, + getFieldCodec, + getFieldConfig, +} from '../src'; +import { + addDecoderSizePrefix, + fixDecoderSize, + getBooleanDecoder, + getBytesDecoder, + getDataEnumCodec, + getOptionDecoder, + getUtf8Decoder, + getU32Decoder, + getU64Decoder, + getStructDecoder, + some, +} from '@solana/codecs'; +import { splDiscriminate } from '@solana/spl-type-length-value'; +import type { Decoder, Option, VariableSizeDecoder } from '@solana/codecs'; +import { PublicKey, type TransactionInstruction } from '@solana/web3.js'; + +function checkPackUnpack( + instruction: TransactionInstruction, + discriminator: Uint8Array, + decoder: Decoder, + values: T, +) { + expect(instruction.data.subarray(0, 8)).to.deep.equal(discriminator); + const unpacked = decoder.decode(instruction.data.subarray(8)); + expect(unpacked).to.deep.equal(values); +} + +function getStringDecoder(): VariableSizeDecoder { + return addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder()); +} + +describe('Token Metadata Instructions', () => { + const programId = new PublicKey('22222222222222222222222222222222222222222222'); + const metadata = new PublicKey('33333333333333333333333333333333333333333333'); + const updateAuthority = new PublicKey('44444444444444444444444444444444444444444444'); + const mint = new PublicKey('55555555555555555555555555555555555555555555'); + const mintAuthority = new PublicKey('66666666666666666666666666666666666666666666'); + + it('Can create Initialize Instruction', async () => { + const name = 'My test token'; + const symbol = 'TEST'; + const uri = 'http://test.test'; + checkPackUnpack( + createInitializeInstruction({ + programId, + metadata, + updateAuthority, + mint, + mintAuthority, + name, + symbol, + uri, + }), + await splDiscriminate('spl_token_metadata_interface:initialize_account'), + getStructDecoder([ + ['name', getStringDecoder()], + ['symbol', getStringDecoder()], + ['uri', getStringDecoder()], + ]), + { name, symbol, uri }, + ); + }); + + it('Can create Update Field Instruction', async () => { + const field = 'MyTestField'; + const value = 'http://test.uri'; + checkPackUnpack( + createUpdateFieldInstruction({ + programId, + metadata, + updateAuthority, + field, + value, + }), + await splDiscriminate('spl_token_metadata_interface:updating_field'), + getStructDecoder([ + ['key', getDataEnumCodec(getFieldCodec())], + ['value', getStringDecoder()], + ]), + { key: getFieldConfig(field), value }, + ); + }); + + it('Can create Update Field Instruction with Field Enum', async () => { + const field = 'Name'; + const value = 'http://test.uri'; + checkPackUnpack( + createUpdateFieldInstruction({ + programId, + metadata, + updateAuthority, + field, + value, + }), + await splDiscriminate('spl_token_metadata_interface:updating_field'), + getStructDecoder([ + ['key', getDataEnumCodec(getFieldCodec())], + ['value', getStringDecoder()], + ]), + { key: getFieldConfig(field), value }, + ); + }); + + it('Can create Remove Key Instruction', async () => { + checkPackUnpack( + createRemoveKeyInstruction({ + programId, + metadata, + updateAuthority: updateAuthority, + key: 'MyTestField', + idempotent: true, + }), + await splDiscriminate('spl_token_metadata_interface:remove_key_ix'), + getStructDecoder([ + ['idempotent', getBooleanDecoder()], + ['key', getStringDecoder()], + ]), + { idempotent: true, key: 'MyTestField' }, + ); + }); + + it('Can create Update Authority Instruction', async () => { + const newAuthority = PublicKey.default; + checkPackUnpack( + createUpdateAuthorityInstruction({ + programId, + metadata, + oldAuthority: updateAuthority, + newAuthority, + }), + await splDiscriminate('spl_token_metadata_interface:update_the_authority'), + getStructDecoder([['newAuthority', fixDecoderSize(getBytesDecoder(), 32)]]), + { newAuthority: Uint8Array.from(newAuthority.toBuffer()) }, + ); + }); + + it('Can create Emit Instruction', async () => { + const start: Option = some(0n); + const end: Option = some(10n); + checkPackUnpack( + createEmitInstruction({ + programId, + metadata, + start: 0n, + end: 10n, + }), + await splDiscriminate('spl_token_metadata_interface:emitter'), + getStructDecoder([ + ['start', getOptionDecoder(getU64Decoder())], + ['end', getOptionDecoder(getU64Decoder())], + ]), + { start, end }, + ); + }); +}); diff --git a/clients/js-legacy/test/state.test.ts b/clients/js-legacy/test/state.test.ts new file mode 100644 index 0000000..f55dd04 --- /dev/null +++ b/clients/js-legacy/test/state.test.ts @@ -0,0 +1,61 @@ +import { PublicKey } from '@solana/web3.js'; +import { expect } from 'chai'; + +import type { TokenMetadata } from '../src/state'; +import { unpack, pack } from '../src'; + +function checkPackUnpack(tokenMetadata: TokenMetadata) { + const packed = pack(tokenMetadata); + const unpacked = unpack(packed); + expect(unpacked).to.deep.equal(tokenMetadata); +} + +describe('Token Metadata State', () => { + it('Can pack and unpack base token metadata', () => { + checkPackUnpack({ + mint: PublicKey.default, + name: 'name', + symbol: 'symbol', + uri: 'uri', + additionalMetadata: [], + }); + }); + + it('Can pack and unpack with updateAuthority', () => { + checkPackUnpack({ + updateAuthority: new PublicKey('44444444444444444444444444444444444444444444'), + mint: new PublicKey('55555555555555555555555555555555555555555555'), + name: 'name', + symbol: 'symbol', + uri: 'uri', + additionalMetadata: [], + }); + }); + + it('Can pack and unpack with additional metadata', () => { + checkPackUnpack({ + mint: PublicKey.default, + name: 'new_name', + symbol: 'new_symbol', + uri: 'new_uri', + additionalMetadata: [ + ['key1', 'value1'], + ['key2', 'value2'], + ], + }); + }); + + it('Can pack and unpack with updateAuthority and additional metadata', () => { + checkPackUnpack({ + updateAuthority: new PublicKey('44444444444444444444444444444444444444444444'), + mint: new PublicKey('55555555555555555555555555555555555555555555'), + name: 'name', + symbol: 'symbol', + uri: 'uri', + additionalMetadata: [ + ['key1', 'value1'], + ['key2', 'value2'], + ], + }); + }); +}); diff --git a/clients/js-legacy/tsconfig.all.json b/clients/js-legacy/tsconfig.all.json new file mode 100644 index 0000000..9855132 --- /dev/null +++ b/clients/js-legacy/tsconfig.all.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.root.json", + "references": [ + { + "path": "./tsconfig.cjs.json" + }, + { + "path": "./tsconfig.esm.json" + } + ] +} diff --git a/clients/js-legacy/tsconfig.base.json b/clients/js-legacy/tsconfig.base.json new file mode 100644 index 0000000..90620c4 --- /dev/null +++ b/clients/js-legacy/tsconfig.base.json @@ -0,0 +1,14 @@ +{ + "include": [], + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Node", + "esModuleInterop": true, + "isolatedModules": true, + "noEmitOnError": true, + "resolveJsonModule": true, + "strict": true, + "stripInternal": true + } +} diff --git a/clients/js-legacy/tsconfig.cjs.json b/clients/js-legacy/tsconfig.cjs.json new file mode 100644 index 0000000..2db9b71 --- /dev/null +++ b/clients/js-legacy/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "outDir": "lib/cjs", + "target": "ES2016", + "module": "CommonJS", + "sourceMap": true + } +} diff --git a/clients/js-legacy/tsconfig.esm.json b/clients/js-legacy/tsconfig.esm.json new file mode 100644 index 0000000..25e7e25 --- /dev/null +++ b/clients/js-legacy/tsconfig.esm.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "outDir": "lib/esm", + "declarationDir": "lib/types", + "target": "ES2020", + "module": "ES2020", + "sourceMap": true, + "declaration": true, + "declarationMap": true + } +} diff --git a/clients/js-legacy/tsconfig.json b/clients/js-legacy/tsconfig.json new file mode 100644 index 0000000..2f9b239 --- /dev/null +++ b/clients/js-legacy/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.all.json", + "include": ["src", "test"], + "compilerOptions": { + "noEmit": true, + "skipLibCheck": true + } +} diff --git a/clients/js-legacy/tsconfig.root.json b/clients/js-legacy/tsconfig.root.json new file mode 100644 index 0000000..fadf294 --- /dev/null +++ b/clients/js-legacy/tsconfig.root.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "composite": true + } +} diff --git a/clients/js-legacy/typedoc.json b/clients/js-legacy/typedoc.json new file mode 100644 index 0000000..c39fc53 --- /dev/null +++ b/clients/js-legacy/typedoc.json @@ -0,0 +1,5 @@ +{ + "entryPoints": ["src/index.ts"], + "out": "docs", + "readme": "README.md" +} diff --git a/interface/Cargo.toml b/interface/Cargo.toml new file mode 100644 index 0000000..66e2695 --- /dev/null +++ b/interface/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "spl-token-metadata-interface" +version = "0.6.0" +description = "Solana Program Library Token Metadata Interface" +authors = ["Solana Labs Maintainers "] +repository = "https://github.com/solana-labs/solana-program-library" +license = "Apache-2.0" +edition = "2021" + +[features] +serde-traits = ["dep:serde", "spl-pod/serde-traits"] + +[dependencies] +borsh = "1.5.3" +num-derive = "0.4" +num-traits = "0.2" +serde = { version = "1.0.216", optional = true } +solana-borsh = "2.1.0" +solana-decode-error = "2.1.0" +solana-instruction = "2.1.0" +solana-msg = "2.1.0" +solana-program-error = "2.1.0" +spl-discriminator = { version = "0.4.0", path = "../../libraries/discriminator" } +solana-pubkey = "2.1.0" +spl-type-length-value = { version = "0.7.0", path = "../../libraries/type-length-value" } +spl-pod = { version = "0.5.0", path = "../../libraries/pod", features = [ + "borsh", +] } +thiserror = "2.0" + +[dev-dependencies] +serde_json = "1.0.133" +solana-sha256-hasher = "2.1.0" + +[lib] +crate-type = ["cdylib", "lib"] + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] diff --git a/interface/README.md b/interface/README.md new file mode 100644 index 0000000..3ee6d40 --- /dev/null +++ b/interface/README.md @@ -0,0 +1,107 @@ +## Token-Metadata Interface + +An interface describing the instructions required for a program to implement +to be considered a "token-metadata" program for SPL token mints. The interface +can be implemented by any program. + +With a common interface, any wallet, dapp, or on-chain program can read the metadata, +and any tool that creates or modifies metadata will just work with any program +that implements the interface. + +There is also a `TokenMetadata` struct that may optionally be implemented, but +is not required because of the `Emit` instruction, which indexers and other off-chain +users can call to get metadata. + +### Example program + +Coming soon! + +### Motivation + +Token creators on Solana need all sorts of functionality for their token-metadata, +and the Metaplex Token-Metadata program has been the one place for all metadata +needs, leading to a feature-rich program that still might not serve all needs. + +At its base, token-metadata is a set of data fields associated to a particular token +mint, so we propose an interface that serves the simplest base case with some +compatibility with existing solutions. + +With this proposal implemented, fungible and non-fungible token creators will +have two options: + +* implement the interface in their own program, so they can eventually extend it +with new functionality or even other interfaces +* use a reference program that implements the simplest case + +### Required Instructions + +All of the following instructions are listed in greater detail in the source code. +Once the interface is decided, the information in the source code will be copied +here. + +#### Initialize + +Initializes the token-metadata TLV entry in an account with an update authority, +name, symbol, and URI. + +Must provide an SPL token mint and be signed by the mint authority. + +#### Update Field + +Updates a field in a token-metadata account. This may be an existing or totally +new field. + +Must be signed by the update authority. + +#### Remove Key + +Unsets a key-value pair, clearing an existing entry. + +Must be signed by the update authority. + +#### Update Authority + +Sets or unsets the token-metadata update authority, which signs any future updates +to the metadata. + +Must be signed by the update authority. + +#### Emit + +Emits token-metadata in the expected `TokenMetadata` state format. Although +implementing a struct that uses the exact state is optional, this instruction is +required. + +### (Optional) State + +A program that implements the interface may write the following data fields +into a type-length-value entry into an account: + +```rust +type Pubkey = [u8; 32]; +type OptionalNonZeroPubkey = Pubkey; // if all zeroes, interpreted as `None` + +pub struct TokenMetadata { + /// The authority that can sign to update the metadata + pub update_authority: OptionalNonZeroPubkey, + /// The associated mint, used to counter spoofing to be sure that metadata + /// belongs to a particular mint + pub mint: Pubkey, + /// The longer name of the token + pub name: String, + /// The shortened symbol for the token + pub symbol: String, + /// The URI pointing to richer metadata + pub uri: String, + /// Any additional metadata about the token as key-value pairs. The program + /// must avoid storing the same key twice. + pub additional_metadata: Vec<(String, String)>, +} +``` + +By storing the metadata in a TLV structure, a developer who implements this +interface in their program can freely add any other data fields in a different +TLV entry. + +You can find more information about TLV / type-length-value structures at the +[spl-type-length-value repo](https://github.com/solana-labs/solana-program-library/tree/master/libraries/type-length-value). diff --git a/interface/src/error.rs b/interface/src/error.rs new file mode 100644 index 0000000..0ec2fa3 --- /dev/null +++ b/interface/src/error.rs @@ -0,0 +1,75 @@ +//! Interface error types + +use { + solana_decode_error::DecodeError, + solana_msg::msg, + solana_program_error::{PrintProgramError, ProgramError}, +}; + +/// Errors that may be returned by the interface. +#[repr(u32)] +#[derive(Clone, Debug, Eq, thiserror::Error, num_derive::FromPrimitive, PartialEq)] +pub enum TokenMetadataError { + /// Incorrect account provided + #[error("Incorrect account provided")] + IncorrectAccount = 901_952_957, + /// Mint has no mint authority + #[error("Mint has no mint authority")] + MintHasNoMintAuthority, + /// Incorrect mint authority has signed the instruction + #[error("Incorrect mint authority has signed the instruction")] + IncorrectMintAuthority, + /// Incorrect metadata update authority has signed the instruction + #[error("Incorrect metadata update authority has signed the instruction")] + IncorrectUpdateAuthority, + /// Token metadata has no update authority + #[error("Token metadata has no update authority")] + ImmutableMetadata, + /// Key not found in metadata account + #[error("Key not found in metadata account")] + KeyNotFound, +} + +impl From for ProgramError { + fn from(e: TokenMetadataError) -> Self { + ProgramError::Custom(e as u32) + } +} + +impl DecodeError for TokenMetadataError { + fn type_of() -> &'static str { + "TokenMetadataError" + } +} + +impl PrintProgramError for TokenMetadataError { + fn print(&self) + where + E: 'static + + std::error::Error + + DecodeError + + PrintProgramError + + num_traits::FromPrimitive, + { + match self { + TokenMetadataError::IncorrectAccount => { + msg!("Incorrect account provided") + } + TokenMetadataError::MintHasNoMintAuthority => { + msg!("Mint has no mint authority") + } + TokenMetadataError::IncorrectMintAuthority => { + msg!("Incorrect mint authority has signed the instruction",) + } + TokenMetadataError::IncorrectUpdateAuthority => { + msg!("Incorrect metadata update authority has signed the instruction",) + } + TokenMetadataError::ImmutableMetadata => { + msg!("Token metadata has no update authority") + } + TokenMetadataError::KeyNotFound => { + msg!("Key not found in metadata account") + } + } + } +} diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs new file mode 100644 index 0000000..5fb27c6 --- /dev/null +++ b/interface/src/instruction.rs @@ -0,0 +1,504 @@ +//! Instruction types + +#[cfg(feature = "serde-traits")] +use serde::{Deserialize, Serialize}; +use { + crate::state::Field, + borsh::{BorshDeserialize, BorshSerialize}, + solana_instruction::{AccountMeta, Instruction}, + solana_program_error::ProgramError, + solana_pubkey::Pubkey, + spl_discriminator::{discriminator::ArrayDiscriminator, SplDiscriminate}, + spl_pod::optional_keys::OptionalNonZeroPubkey, +}; + +/// Initialization instruction data +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(rename_all = "camelCase"))] +#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize, SplDiscriminate)] +#[discriminator_hash_input("spl_token_metadata_interface:initialize_account")] +pub struct Initialize { + /// Longer name of the token + pub name: String, + /// Shortened symbol of the token + pub symbol: String, + /// URI pointing to more metadata (image, video, etc.) + pub uri: String, +} + +/// Update field instruction data +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(rename_all = "camelCase"))] +#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize, SplDiscriminate)] +#[discriminator_hash_input("spl_token_metadata_interface:updating_field")] +pub struct UpdateField { + /// Field to update in the metadata + pub field: Field, + /// Value to write for the field + pub value: String, +} + +/// Remove key instruction data +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(rename_all = "camelCase"))] +#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize, SplDiscriminate)] +#[discriminator_hash_input("spl_token_metadata_interface:remove_key_ix")] +pub struct RemoveKey { + /// If the idempotent flag is set to true, then the instruction will not + /// error if the key does not exist + pub idempotent: bool, + /// Key to remove in the additional metadata portion + pub key: String, +} + +/// Update authority instruction data +#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize, SplDiscriminate)] +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(rename_all = "camelCase"))] +#[discriminator_hash_input("spl_token_metadata_interface:update_the_authority")] +pub struct UpdateAuthority { + /// New authority for the token metadata, or unset if `None` + pub new_authority: OptionalNonZeroPubkey, +} + +/// Instruction data for Emit +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(rename_all = "camelCase"))] +#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize, SplDiscriminate)] +#[discriminator_hash_input("spl_token_metadata_interface:emitter")] +pub struct Emit { + /// Start of range of data to emit + pub start: Option, + /// End of range of data to emit + pub end: Option, +} + +/// All instructions that must be implemented in the token-metadata interface +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(rename_all = "camelCase"))] +#[derive(Clone, Debug, PartialEq)] +pub enum TokenMetadataInstruction { + /// Initializes a TLV entry with the basic token-metadata fields. + /// + /// Assumes that the provided mint is an SPL token mint, that the metadata + /// account is allocated and assigned to the program, and that the metadata + /// account has enough lamports to cover the rent-exempt reserve. + /// + /// Accounts expected by this instruction: + /// + /// 0. `[w]` Metadata + /// 1. `[]` Update authority + /// 2. `[]` Mint + /// 3. `[s]` Mint authority + /// + /// Data: `Initialize` data, name / symbol / uri strings + Initialize(Initialize), + + /// Updates 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. + /// + /// By the end of the instruction, the metadata account must be properly + /// resized based on the new size of the TLV entry. + /// * If the new size is larger, the program must first reallocate to + /// avoid overwriting other TLV entries. + /// * If the new size is smaller, the program must reallocate at the end + /// so that it's possible to iterate over TLV entries + /// + /// Accounts expected by this instruction: + /// + /// 0. `[w]` Metadata account + /// 1. `[s]` Update authority + /// + /// Data: `UpdateField` data, specifying the new field and value. If the + /// field does not exist on the account, it will be created. If the + /// field does exist, it will be overwritten. + UpdateField(UpdateField), + + /// Removes a key-value pair in a token-metadata account. + /// + /// This only applies to additional fields, and not the base name / symbol / + /// URI fields. + /// + /// By the end of the instruction, the metadata account must be properly + /// resized at the end based on the new size of the TLV entry. + /// + /// Accounts expected by this instruction: + /// + /// 0. `[w]` Metadata account + /// 1. `[s]` Update authority + /// + /// Data: the string key to remove. If the idempotent flag is set to false, + /// returns an error if the key is not present + RemoveKey(RemoveKey), + + /// Updates the token-metadata authority + /// + /// Accounts expected by this instruction: + /// + /// 0. `[w]` Metadata account + /// 1. `[s]` Current update authority + /// + /// Data: the new authority. Can be unset using a `None` value + UpdateAuthority(UpdateAuthority), + + /// Emits the token-metadata as return data + /// + /// The format of the data emitted follows exactly the `TokenMetadata` + /// struct, but it's possible that the account data is stored in another + /// format by the program. + /// + /// With this instruction, a program that implements the token-metadata + /// interface can return `TokenMetadata` without adhering to the specific + /// byte layout of the `TokenMetadata` struct in any accounts. + /// + /// Accounts expected by this instruction: + /// + /// 0. `[]` Metadata account + Emit(Emit), +} +impl TokenMetadataInstruction { + /// Unpacks a byte buffer into a + /// [TokenMetadataInstruction](enum.TokenMetadataInstruction.html). + pub fn unpack(input: &[u8]) -> Result { + if input.len() < ArrayDiscriminator::LENGTH { + return Err(ProgramError::InvalidInstructionData); + } + let (discriminator, rest) = input.split_at(ArrayDiscriminator::LENGTH); + Ok(match discriminator { + Initialize::SPL_DISCRIMINATOR_SLICE => { + let data = Initialize::try_from_slice(rest)?; + Self::Initialize(data) + } + UpdateField::SPL_DISCRIMINATOR_SLICE => { + let data = UpdateField::try_from_slice(rest)?; + Self::UpdateField(data) + } + RemoveKey::SPL_DISCRIMINATOR_SLICE => { + let data = RemoveKey::try_from_slice(rest)?; + Self::RemoveKey(data) + } + UpdateAuthority::SPL_DISCRIMINATOR_SLICE => { + let data = UpdateAuthority::try_from_slice(rest)?; + Self::UpdateAuthority(data) + } + Emit::SPL_DISCRIMINATOR_SLICE => { + let data = Emit::try_from_slice(rest)?; + Self::Emit(data) + } + _ => return Err(ProgramError::InvalidInstructionData), + }) + } + + /// Packs a [TokenInstruction](enum.TokenInstruction.html) into a byte + /// buffer. + pub fn pack(&self) -> Vec { + let mut buf = vec![]; + match self { + Self::Initialize(data) => { + buf.extend_from_slice(Initialize::SPL_DISCRIMINATOR_SLICE); + buf.append(&mut borsh::to_vec(data).unwrap()); + } + Self::UpdateField(data) => { + buf.extend_from_slice(UpdateField::SPL_DISCRIMINATOR_SLICE); + buf.append(&mut borsh::to_vec(data).unwrap()); + } + Self::RemoveKey(data) => { + buf.extend_from_slice(RemoveKey::SPL_DISCRIMINATOR_SLICE); + buf.append(&mut borsh::to_vec(data).unwrap()); + } + Self::UpdateAuthority(data) => { + buf.extend_from_slice(UpdateAuthority::SPL_DISCRIMINATOR_SLICE); + buf.append(&mut borsh::to_vec(data).unwrap()); + } + Self::Emit(data) => { + buf.extend_from_slice(Emit::SPL_DISCRIMINATOR_SLICE); + buf.append(&mut borsh::to_vec(data).unwrap()); + } + }; + buf + } +} + +/// Creates an `Initialize` instruction +#[allow(clippy::too_many_arguments)] +pub fn initialize( + program_id: &Pubkey, + metadata: &Pubkey, + update_authority: &Pubkey, + mint: &Pubkey, + mint_authority: &Pubkey, + name: String, + symbol: String, + uri: String, +) -> Instruction { + let data = TokenMetadataInstruction::Initialize(Initialize { name, symbol, uri }); + Instruction { + program_id: *program_id, + accounts: vec![ + AccountMeta::new(*metadata, false), + AccountMeta::new_readonly(*update_authority, false), + AccountMeta::new_readonly(*mint, false), + AccountMeta::new_readonly(*mint_authority, true), + ], + data: data.pack(), + } +} + +/// Creates an `UpdateField` instruction +pub fn update_field( + program_id: &Pubkey, + metadata: &Pubkey, + update_authority: &Pubkey, + field: Field, + value: String, +) -> Instruction { + let data = TokenMetadataInstruction::UpdateField(UpdateField { field, value }); + Instruction { + program_id: *program_id, + accounts: vec![ + AccountMeta::new(*metadata, false), + AccountMeta::new_readonly(*update_authority, true), + ], + data: data.pack(), + } +} + +/// Creates a `RemoveKey` instruction +pub fn remove_key( + program_id: &Pubkey, + metadata: &Pubkey, + update_authority: &Pubkey, + key: String, + idempotent: bool, +) -> Instruction { + let data = TokenMetadataInstruction::RemoveKey(RemoveKey { key, idempotent }); + Instruction { + program_id: *program_id, + accounts: vec![ + AccountMeta::new(*metadata, false), + AccountMeta::new_readonly(*update_authority, true), + ], + data: data.pack(), + } +} + +/// Creates an `UpdateAuthority` instruction +pub fn update_authority( + program_id: &Pubkey, + metadata: &Pubkey, + current_authority: &Pubkey, + new_authority: OptionalNonZeroPubkey, +) -> Instruction { + let data = TokenMetadataInstruction::UpdateAuthority(UpdateAuthority { new_authority }); + Instruction { + program_id: *program_id, + accounts: vec![ + AccountMeta::new(*metadata, false), + AccountMeta::new_readonly(*current_authority, true), + ], + data: data.pack(), + } +} + +/// Creates an `Emit` instruction +pub fn emit( + program_id: &Pubkey, + metadata: &Pubkey, + start: Option, + end: Option, +) -> Instruction { + let data = TokenMetadataInstruction::Emit(Emit { start, end }); + Instruction { + program_id: *program_id, + accounts: vec![AccountMeta::new_readonly(*metadata, false)], + data: data.pack(), + } +} + +#[cfg(test)] +mod test { + #[cfg(feature = "serde-traits")] + use std::str::FromStr; + use {super::*, crate::NAMESPACE, solana_sha256_hasher::hashv}; + + fn check_pack_unpack( + instruction: TokenMetadataInstruction, + discriminator: &[u8], + data: T, + ) { + let mut expect = vec![]; + expect.extend_from_slice(discriminator.as_ref()); + expect.append(&mut borsh::to_vec(&data).unwrap()); + let packed = instruction.pack(); + assert_eq!(packed, expect); + let unpacked = TokenMetadataInstruction::unpack(&expect).unwrap(); + assert_eq!(unpacked, instruction); + } + + #[test] + fn initialize_pack() { + let name = "My test token"; + let symbol = "TEST"; + let uri = "http://test.test"; + let data = Initialize { + name: name.to_string(), + symbol: symbol.to_string(), + uri: uri.to_string(), + }; + let check = TokenMetadataInstruction::Initialize(data.clone()); + let preimage = hashv(&[format!("{NAMESPACE}:initialize_account").as_bytes()]); + let discriminator = &preimage.as_ref()[..ArrayDiscriminator::LENGTH]; + check_pack_unpack(check, discriminator, data); + } + + #[test] + fn update_field_pack() { + let field = "MyTestField"; + let value = "http://test.uri"; + let data = UpdateField { + field: Field::Key(field.to_string()), + value: value.to_string(), + }; + let check = TokenMetadataInstruction::UpdateField(data.clone()); + let preimage = hashv(&[format!("{NAMESPACE}:updating_field").as_bytes()]); + let discriminator = &preimage.as_ref()[..ArrayDiscriminator::LENGTH]; + check_pack_unpack(check, discriminator, data); + } + + #[test] + fn remove_key_pack() { + let data = RemoveKey { + key: "MyTestField".to_string(), + idempotent: true, + }; + let check = TokenMetadataInstruction::RemoveKey(data.clone()); + let preimage = hashv(&[format!("{NAMESPACE}:remove_key_ix").as_bytes()]); + let discriminator = &preimage.as_ref()[..ArrayDiscriminator::LENGTH]; + check_pack_unpack(check, discriminator, data); + } + + #[test] + fn update_authority_pack() { + let data = UpdateAuthority { + new_authority: OptionalNonZeroPubkey::default(), + }; + let check = TokenMetadataInstruction::UpdateAuthority(data.clone()); + let preimage = hashv(&[format!("{NAMESPACE}:update_the_authority").as_bytes()]); + let discriminator = &preimage.as_ref()[..ArrayDiscriminator::LENGTH]; + check_pack_unpack(check, discriminator, data); + } + + #[test] + fn emit_pack() { + let data = Emit { + start: None, + end: Some(10), + }; + let check = TokenMetadataInstruction::Emit(data.clone()); + let preimage = hashv(&[format!("{NAMESPACE}:emitter").as_bytes()]); + let discriminator = &preimage.as_ref()[..ArrayDiscriminator::LENGTH]; + check_pack_unpack(check, discriminator, data); + } + + #[cfg(feature = "serde-traits")] + #[test] + fn initialize_serde() { + let data = Initialize { + name: "Token Name".to_string(), + symbol: "TST".to_string(), + uri: "uri.test".to_string(), + }; + let ix = TokenMetadataInstruction::Initialize(data); + let serialized = serde_json::to_string(&ix).unwrap(); + let serialized_expected = + "{\"initialize\":{\"name\":\"Token Name\",\"symbol\":\"TST\",\"uri\":\"uri.test\"}}"; + assert_eq!(&serialized, serialized_expected); + + let deserialized = serde_json::from_str::(&serialized).unwrap(); + assert_eq!(ix, deserialized); + } + + #[cfg(feature = "serde-traits")] + #[test] + fn update_field_serde() { + let data = UpdateField { + field: Field::Key("MyField".to_string()), + value: "my field value".to_string(), + }; + let ix = TokenMetadataInstruction::UpdateField(data); + let serialized = serde_json::to_string(&ix).unwrap(); + let serialized_expected = + "{\"updateField\":{\"field\":{\"key\":\"MyField\"},\"value\":\"my field value\"}}"; + assert_eq!(&serialized, serialized_expected); + + let deserialized = serde_json::from_str::(&serialized).unwrap(); + assert_eq!(ix, deserialized); + } + + #[cfg(feature = "serde-traits")] + #[test] + fn remove_key_serde() { + let data = RemoveKey { + key: "MyTestField".to_string(), + idempotent: true, + }; + let ix = TokenMetadataInstruction::RemoveKey(data); + let serialized = serde_json::to_string(&ix).unwrap(); + let serialized_expected = "{\"removeKey\":{\"idempotent\":true,\"key\":\"MyTestField\"}}"; + assert_eq!(&serialized, serialized_expected); + + let deserialized = serde_json::from_str::(&serialized).unwrap(); + assert_eq!(ix, deserialized); + } + + #[cfg(feature = "serde-traits")] + #[test] + fn update_authority_serde() { + let update_authority_option: Option = + Some(Pubkey::from_str("4uQeVj5tqViQh7yWWGStvkEG1Zmhx6uasJtWCJziofM").unwrap()); + let update_authority: OptionalNonZeroPubkey = update_authority_option.try_into().unwrap(); + let data = UpdateAuthority { + new_authority: update_authority, + }; + let ix = TokenMetadataInstruction::UpdateAuthority(data); + let serialized = serde_json::to_string(&ix).unwrap(); + let serialized_expected = "{\"updateAuthority\":{\"newAuthority\":\"4uQeVj5tqViQh7yWWGStvkEG1Zmhx6uasJtWCJziofM\"}}"; + assert_eq!(&serialized, serialized_expected); + + let deserialized = serde_json::from_str::(&serialized).unwrap(); + assert_eq!(ix, deserialized); + } + + #[cfg(feature = "serde-traits")] + #[test] + fn update_authority_serde_with_none() { + let data = UpdateAuthority { + new_authority: OptionalNonZeroPubkey::default(), + }; + let ix = TokenMetadataInstruction::UpdateAuthority(data); + let serialized = serde_json::to_string(&ix).unwrap(); + let serialized_expected = "{\"updateAuthority\":{\"newAuthority\":null}}"; + assert_eq!(&serialized, serialized_expected); + + let deserialized = serde_json::from_str::(&serialized).unwrap(); + assert_eq!(ix, deserialized); + } + + #[cfg(feature = "serde-traits")] + #[test] + fn emit_serde() { + let data = Emit { + start: None, + end: Some(10), + }; + let ix = TokenMetadataInstruction::Emit(data); + let serialized = serde_json::to_string(&ix).unwrap(); + let serialized_expected = "{\"emit\":{\"start\":null,\"end\":10}}"; + assert_eq!(&serialized, serialized_expected); + + let deserialized = serde_json::from_str::(&serialized).unwrap(); + assert_eq!(ix, deserialized); + } +} diff --git a/interface/src/lib.rs b/interface/src/lib.rs new file mode 100644 index 0000000..26d4e21 --- /dev/null +++ b/interface/src/lib.rs @@ -0,0 +1,18 @@ +//! Crate defining an interface for token-metadata + +#![allow(clippy::arithmetic_side_effects)] +#![deny(missing_docs)] +#![cfg_attr(not(test), forbid(unsafe_code))] + +pub mod error; +pub mod instruction; +pub mod state; + +// Export current sdk types for downstream users building with a different sdk +// version Export borsh for downstream users +pub use { + borsh, solana_borsh, solana_decode_error, solana_instruction, solana_msg, solana_program_error, +}; + +/// Namespace for all programs implementing token-metadata +pub const NAMESPACE: &str = "spl_token_metadata_interface"; diff --git a/interface/src/state.rs b/interface/src/state.rs new file mode 100644 index 0000000..e1404d2 --- /dev/null +++ b/interface/src/state.rs @@ -0,0 +1,219 @@ +//! Token-metadata interface state types + +#[cfg(feature = "serde-traits")] +use serde::{Deserialize, Serialize}; +use { + borsh::{BorshDeserialize, BorshSchema, BorshSerialize}, + solana_borsh::v1::{get_instance_packed_len, try_from_slice_unchecked}, + solana_program_error::ProgramError, + solana_pubkey::Pubkey, + spl_discriminator::{ArrayDiscriminator, SplDiscriminate}, + spl_pod::optional_keys::OptionalNonZeroPubkey, + spl_type_length_value::{ + state::{TlvState, TlvStateBorrowed}, + variable_len_pack::VariableLenPack, + }, +}; + +/// Data struct for all token-metadata, stored in a TLV entry +/// +/// The type and length parts must be handled by the TLV library, and not stored +/// as part of this struct. +#[derive(Clone, Debug, Default, PartialEq, BorshDeserialize, BorshSerialize, BorshSchema)] +pub struct TokenMetadata { + /// The authority that can sign to update the metadata + pub update_authority: OptionalNonZeroPubkey, + /// The associated mint, used to counter spoofing to be sure that metadata + /// belongs to a particular mint + pub mint: Pubkey, + /// The longer name of the token + pub name: String, + /// The shortened symbol for the token + pub symbol: String, + /// The URI pointing to richer metadata + pub uri: String, + /// Any additional metadata about the token as key-value pairs. The program + /// must avoid storing the same key twice. + pub additional_metadata: Vec<(String, String)>, +} +impl SplDiscriminate for TokenMetadata { + /// Please use this discriminator in your program when matching + const SPL_DISCRIMINATOR: ArrayDiscriminator = + ArrayDiscriminator::new([112, 132, 90, 90, 11, 88, 157, 87]); +} +impl TokenMetadata { + /// Gives the total size of this struct as a TLV entry in an account + pub fn tlv_size_of(&self) -> Result { + TlvStateBorrowed::get_base_len() + .checked_add(get_instance_packed_len(self)?) + .ok_or(ProgramError::InvalidAccountData) + } + + /// Updates a field in the metadata struct + pub fn update(&mut self, field: Field, value: String) { + match field { + Field::Name => self.name = value, + Field::Symbol => self.symbol = value, + Field::Uri => self.uri = value, + Field::Key(key) => self.set_key_value(key, value), + } + } + + /// Sets a key-value pair in the additional metadata + /// + /// If the key is already present, overwrites the existing entry. Otherwise, + /// adds it to the end. + pub fn set_key_value(&mut self, new_key: String, new_value: String) { + for (key, value) in self.additional_metadata.iter_mut() { + if *key == new_key { + value.replace_range(.., &new_value); + return; + } + } + self.additional_metadata.push((new_key, new_value)); + } + + /// Removes the key-value pair given by the provided key. Returns true if + /// the key was found. + pub fn remove_key(&mut self, key: &str) -> bool { + let mut found_key = false; + self.additional_metadata.retain(|x| { + let should_retain = x.0 != key; + if !should_retain { + found_key = true; + } + should_retain + }); + found_key + } + + /// Get the slice corresponding to the given start and end range + pub fn get_slice(data: &[u8], start: Option, end: Option) -> Option<&[u8]> { + let start = start.unwrap_or(0) as usize; + let end = end.map(|x| x as usize).unwrap_or(data.len()); + data.get(start..end) + } +} +impl VariableLenPack for TokenMetadata { + fn pack_into_slice(&self, dst: &mut [u8]) -> Result<(), ProgramError> { + borsh::to_writer(&mut dst[..], self).map_err(Into::into) + } + fn unpack_from_slice(src: &[u8]) -> Result { + try_from_slice_unchecked(src).map_err(Into::into) + } + fn get_packed_len(&self) -> Result { + get_instance_packed_len(self).map_err(Into::into) + } +} + +/// Fields in the metadata account, used for updating +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(rename_all = "camelCase"))] +#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize)] +pub enum Field { + /// The name field, corresponding to `TokenMetadata.name` + Name, + /// The symbol field, corresponding to `TokenMetadata.symbol` + Symbol, + /// The uri field, corresponding to `TokenMetadata.uri` + Uri, + /// A user field, whose key is given by the associated string + Key(String), +} + +#[cfg(test)] +mod tests { + use {super::*, crate::NAMESPACE, solana_sha256_hasher::hashv}; + + #[test] + fn discriminator() { + let preimage = hashv(&[format!("{NAMESPACE}:token_metadata").as_bytes()]); + let discriminator = + ArrayDiscriminator::try_from(&preimage.as_ref()[..ArrayDiscriminator::LENGTH]).unwrap(); + assert_eq!(TokenMetadata::SPL_DISCRIMINATOR, discriminator); + } + + #[test] + fn update() { + let name = "name".to_string(); + let symbol = "symbol".to_string(); + let uri = "uri".to_string(); + let mut token_metadata = TokenMetadata { + name, + symbol, + uri, + ..Default::default() + }; + + // updating base fields + let new_name = "new_name".to_string(); + token_metadata.update(Field::Name, new_name.clone()); + assert_eq!(token_metadata.name, new_name); + + let new_symbol = "new_symbol".to_string(); + token_metadata.update(Field::Symbol, new_symbol.clone()); + assert_eq!(token_metadata.symbol, new_symbol); + + let new_uri = "new_uri".to_string(); + token_metadata.update(Field::Uri, new_uri.clone()); + assert_eq!(token_metadata.uri, new_uri); + + // add new key-value pairs + let key1 = "key1".to_string(); + let value1 = "value1".to_string(); + token_metadata.update(Field::Key(key1.clone()), value1.clone()); + assert_eq!(token_metadata.additional_metadata.len(), 1); + assert_eq!( + token_metadata.additional_metadata[0], + (key1.clone(), value1.clone()) + ); + + let key2 = "key2".to_string(); + let value2 = "value2".to_string(); + token_metadata.update(Field::Key(key2.clone()), value2.clone()); + assert_eq!(token_metadata.additional_metadata.len(), 2); + assert_eq!( + token_metadata.additional_metadata[0], + (key1.clone(), value1) + ); + assert_eq!( + token_metadata.additional_metadata[1], + (key2.clone(), value2.clone()) + ); + + // update first key, see that order is preserved + let new_value1 = "new_value1".to_string(); + token_metadata.update(Field::Key(key1.clone()), new_value1.clone()); + assert_eq!(token_metadata.additional_metadata.len(), 2); + assert_eq!(token_metadata.additional_metadata[0], (key1, new_value1)); + assert_eq!(token_metadata.additional_metadata[1], (key2, value2)); + } + + #[test] + fn remove_key() { + let name = "name".to_string(); + let symbol = "symbol".to_string(); + let uri = "uri".to_string(); + let mut token_metadata = TokenMetadata { + name, + symbol, + uri, + ..Default::default() + }; + + // add new key-value pair + let key = "key".to_string(); + let value = "value".to_string(); + token_metadata.update(Field::Key(key.clone()), value.clone()); + assert_eq!(token_metadata.additional_metadata.len(), 1); + assert_eq!(token_metadata.additional_metadata[0], (key.clone(), value)); + + // remove it + assert!(token_metadata.remove_key(&key)); + assert_eq!(token_metadata.additional_metadata.len(), 0); + + // remove it again, returns false + assert!(!token_metadata.remove_key(&key)); + assert_eq!(token_metadata.additional_metadata.len(), 0); + } +} diff --git a/program/Cargo.toml b/program/Cargo.toml new file mode 100644 index 0000000..b6a4ef3 --- /dev/null +++ b/program/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "spl-token-metadata-example" +version = "0.3.0" +description = "Solana Program Library Token Metadata Example Program" +authors = ["Solana Labs Maintainers "] +repository = "https://github.com/solana-labs/solana-program-library" +license = "Apache-2.0" +edition = "2021" + +[features] +no-entrypoint = [] +test-sbf = [] + +[dependencies] +solana-program = "2.1.0" +spl-token-2022 = { version = "6.0.0", path = "../../token/program-2022", features = ["no-entrypoint"] } +spl-token-metadata-interface = { version = "0.6.0", path = "../interface" } +spl-type-length-value = { version = "0.7.0", path = "../../libraries/type-length-value" } +spl-pod = { version = "0.5.0", path = "../../libraries/pod" } + +[dev-dependencies] +solana-program-test = "2.1.0" +solana-sdk = "2.1.0" +spl-token-client = { version = "0.13.0", path = "../../token/client" } +test-case = "3.3" + +[lib] +crate-type = ["cdylib", "lib"] + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] diff --git a/program/src/entrypoint.rs b/program/src/entrypoint.rs new file mode 100644 index 0000000..accd30f --- /dev/null +++ b/program/src/entrypoint.rs @@ -0,0 +1,24 @@ +//! Program entrypoint + +use { + crate::processor, + solana_program::{ + account_info::AccountInfo, entrypoint::ProgramResult, program_error::PrintProgramError, + pubkey::Pubkey, + }, + spl_token_metadata_interface::error::TokenMetadataError, +}; + +solana_program::entrypoint!(process_instruction); +fn process_instruction( + program_id: &Pubkey, + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> ProgramResult { + if let Err(error) = processor::process(program_id, accounts, instruction_data) { + // catch the error so we can print it + error.print::(); + return Err(error); + } + Ok(()) +} diff --git a/program/src/lib.rs b/program/src/lib.rs new file mode 100644 index 0000000..db86409 --- /dev/null +++ b/program/src/lib.rs @@ -0,0 +1,10 @@ +//! Crate defining an example program for storing SPL token metadata + +#![allow(clippy::arithmetic_side_effects)] +#![deny(missing_docs)] +#![cfg_attr(not(test), forbid(unsafe_code))] + +pub mod processor; + +#[cfg(not(feature = "no-entrypoint"))] +mod entrypoint; diff --git a/program/src/processor.rs b/program/src/processor.rs new file mode 100644 index 0000000..fe686e5 --- /dev/null +++ b/program/src/processor.rs @@ -0,0 +1,222 @@ +//! Program state processor + +use { + solana_program::{ + account_info::{next_account_info, AccountInfo}, + borsh1::get_instance_packed_len, + entrypoint::ProgramResult, + msg, + program::set_return_data, + program_error::ProgramError, + program_option::COption, + pubkey::Pubkey, + }, + spl_pod::optional_keys::OptionalNonZeroPubkey, + spl_token_2022::{extension::StateWithExtensions, state::Mint}, + spl_token_metadata_interface::{ + error::TokenMetadataError, + instruction::{ + Emit, Initialize, RemoveKey, TokenMetadataInstruction, UpdateAuthority, UpdateField, + }, + state::TokenMetadata, + }, + spl_type_length_value::state::{ + realloc_and_pack_first_variable_len, TlvState, TlvStateBorrowed, TlvStateMut, + }, +}; + +fn check_update_authority( + update_authority_info: &AccountInfo, + expected_update_authority: &OptionalNonZeroPubkey, +) -> Result<(), ProgramError> { + if !update_authority_info.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + let update_authority = Option::::from(*expected_update_authority) + .ok_or(TokenMetadataError::ImmutableMetadata)?; + if update_authority != *update_authority_info.key { + return Err(TokenMetadataError::IncorrectUpdateAuthority.into()); + } + Ok(()) +} + +/// Processes a [Initialize](enum.TokenMetadataInstruction.html) instruction. +pub fn process_initialize( + _program_id: &Pubkey, + accounts: &[AccountInfo], + data: Initialize, +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + + let metadata_info = next_account_info(account_info_iter)?; + let update_authority_info = next_account_info(account_info_iter)?; + let mint_info = next_account_info(account_info_iter)?; + let mint_authority_info = next_account_info(account_info_iter)?; + + // scope the mint authority check, in case the mint is in the same account! + { + // IMPORTANT: this example metadata program is designed to work with any + // program that implements the SPL token interface, so there is no + // ownership check on the mint account. + let mint_data = mint_info.try_borrow_data()?; + let mint = StateWithExtensions::::unpack(&mint_data)?; + + if !mint_authority_info.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + if mint.base.mint_authority.as_ref() != COption::Some(mint_authority_info.key) { + return Err(TokenMetadataError::IncorrectMintAuthority.into()); + } + } + + // get the required size, assumes that there's enough space for the entry + let update_authority = OptionalNonZeroPubkey::try_from(Some(*update_authority_info.key))?; + let token_metadata = TokenMetadata { + name: data.name, + symbol: data.symbol, + uri: data.uri, + update_authority, + mint: *mint_info.key, + ..Default::default() + }; + let instance_size = get_instance_packed_len(&token_metadata)?; + + // allocate a TLV entry for the space and write it in + let mut buffer = metadata_info.try_borrow_mut_data()?; + let mut state = TlvStateMut::unpack(&mut buffer)?; + state.alloc::(instance_size, false)?; + state.pack_first_variable_len_value(&token_metadata)?; + + Ok(()) +} + +/// Processes an [UpdateField](enum.TokenMetadataInstruction.html) instruction. +pub fn process_update_field( + _program_id: &Pubkey, + accounts: &[AccountInfo], + data: UpdateField, +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let metadata_info = next_account_info(account_info_iter)?; + let update_authority_info = next_account_info(account_info_iter)?; + + // deserialize the metadata, but scope the data borrow since we'll probably + // realloc the account + let mut token_metadata = { + let buffer = metadata_info.try_borrow_data()?; + let state = TlvStateBorrowed::unpack(&buffer)?; + state.get_first_variable_len_value::()? + }; + + check_update_authority(update_authority_info, &token_metadata.update_authority)?; + + // Update the field + token_metadata.update(data.field, data.value); + + // Update / realloc the account + realloc_and_pack_first_variable_len(metadata_info, &token_metadata)?; + + Ok(()) +} + +/// Processes a [RemoveKey](enum.TokenMetadataInstruction.html) instruction. +pub fn process_remove_key( + _program_id: &Pubkey, + accounts: &[AccountInfo], + data: RemoveKey, +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let metadata_info = next_account_info(account_info_iter)?; + let update_authority_info = next_account_info(account_info_iter)?; + + // deserialize the metadata, but scope the data borrow since we'll probably + // realloc the account + let mut token_metadata = { + let buffer = metadata_info.try_borrow_data()?; + let state = TlvStateBorrowed::unpack(&buffer)?; + state.get_first_variable_len_value::()? + }; + + check_update_authority(update_authority_info, &token_metadata.update_authority)?; + if !token_metadata.remove_key(&data.key) && !data.idempotent { + return Err(TokenMetadataError::KeyNotFound.into()); + } + realloc_and_pack_first_variable_len(metadata_info, &token_metadata)?; + + Ok(()) +} + +/// Processes a [UpdateAuthority](enum.TokenMetadataInstruction.html) +/// instruction. +pub fn process_update_authority( + _program_id: &Pubkey, + accounts: &[AccountInfo], + data: UpdateAuthority, +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let metadata_info = next_account_info(account_info_iter)?; + let update_authority_info = next_account_info(account_info_iter)?; + + // deserialize the metadata, but scope the data borrow since we'll probably + // realloc the account + let mut token_metadata = { + let buffer = metadata_info.try_borrow_data()?; + let state = TlvStateBorrowed::unpack(&buffer)?; + state.get_first_variable_len_value::()? + }; + + check_update_authority(update_authority_info, &token_metadata.update_authority)?; + token_metadata.update_authority = data.new_authority; + // Update the account, no realloc needed! + realloc_and_pack_first_variable_len(metadata_info, &token_metadata)?; + + Ok(()) +} + +/// Processes an [Emit](enum.TokenMetadataInstruction.html) instruction. +pub fn process_emit(program_id: &Pubkey, accounts: &[AccountInfo], data: Emit) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let metadata_info = next_account_info(account_info_iter)?; + + if metadata_info.owner != program_id { + return Err(ProgramError::IllegalOwner); + } + + let buffer = metadata_info.try_borrow_data()?; + let state = TlvStateBorrowed::unpack(&buffer)?; + let metadata_bytes = state.get_first_bytes::()?; + + if let Some(range) = TokenMetadata::get_slice(metadata_bytes, data.start, data.end) { + set_return_data(range); + } + + Ok(()) +} + +/// Processes an [Instruction](enum.Instruction.html). +pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult { + let instruction = TokenMetadataInstruction::unpack(input)?; + + match instruction { + TokenMetadataInstruction::Initialize(data) => { + msg!("Instruction: Initialize"); + process_initialize(program_id, accounts, data) + } + TokenMetadataInstruction::UpdateField(data) => { + msg!("Instruction: UpdateField"); + process_update_field(program_id, accounts, data) + } + TokenMetadataInstruction::RemoveKey(data) => { + msg!("Instruction: RemoveKey"); + process_remove_key(program_id, accounts, data) + } + TokenMetadataInstruction::UpdateAuthority(data) => { + msg!("Instruction: UpdateAuthority"); + process_update_authority(program_id, accounts, data) + } + TokenMetadataInstruction::Emit(data) => { + msg!("Instruction: Emit"); + process_emit(program_id, accounts, data) + } + } +} diff --git a/program/tests/emit.rs b/program/tests/emit.rs new file mode 100644 index 0000000..bfac603 --- /dev/null +++ b/program/tests/emit.rs @@ -0,0 +1,105 @@ +#![cfg(feature = "test-sbf")] + +mod program_test; +use { + program_test::{setup, setup_metadata, setup_mint}, + solana_program_test::tokio, + solana_sdk::{ + borsh1::try_from_slice_unchecked, program::MAX_RETURN_DATA, pubkey::Pubkey, + signature::Signer, signer::keypair::Keypair, transaction::Transaction, + }, + spl_token_metadata_interface::{borsh, instruction::emit, state::TokenMetadata}, + test_case::test_case, +}; + +#[test_case(Some(40), Some(40) ; "zero bytes")] +#[test_case(Some(40), Some(41) ; "one byte")] +#[test_case(Some(1_000_000), Some(1_000_001) ; "too far")] +#[test_case(Some(50), Some(49) ; "wrong way")] +#[test_case(Some(50), None ; "truncate start")] +#[test_case(None, Some(50) ; "truncate end")] +#[test_case(None, None ; "full data")] +#[tokio::test] +async fn success(start: Option, end: Option) { + let program_id = Pubkey::new_unique(); + let (context, client, payer) = setup(&program_id).await; + + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + + let token_program_id = spl_token_2022::id(); + let decimals = 2; + let token = setup_mint( + &token_program_id, + &mint_authority_pubkey, + decimals, + payer.clone(), + client.clone(), + ) + .await; + let mut context = context.lock().await; + + let update_authority = Pubkey::new_unique(); + let name = "MySuperCoolToken".to_string(); + let symbol = "MINE".to_string(); + let uri = "my.super.cool.token".to_string(); + let token_metadata = TokenMetadata { + name, + symbol, + uri, + update_authority: Some(update_authority).try_into().unwrap(), + mint: *token.get_address(), + ..Default::default() + }; + + let metadata_keypair = Keypair::new(); + let metadata_pubkey = metadata_keypair.pubkey(); + + setup_metadata( + &mut context, + &program_id, + token.get_address(), + &token_metadata, + &metadata_keypair, + &mint_authority, + ) + .await; + + let transaction = Transaction::new_signed_with_payer( + &[emit(&program_id, &metadata_pubkey, start, end)], + Some(&payer.pubkey()), + &[payer.as_ref()], + context.last_blockhash, + ); + let simulation = context + .banks_client + .simulate_transaction(transaction) + .await + .unwrap(); + + let metadata_buffer = borsh::to_vec(&token_metadata).unwrap(); + if let Some(check_buffer) = TokenMetadata::get_slice(&metadata_buffer, start, end) { + if !check_buffer.is_empty() { + // pad the data if necessary + let mut return_data = vec![0; MAX_RETURN_DATA]; + let simulation_return_data = + simulation.simulation_details.unwrap().return_data.unwrap(); + assert_eq!(simulation_return_data.program_id, program_id); + return_data[..simulation_return_data.data.len()] + .copy_from_slice(&simulation_return_data.data); + + assert_eq!(*check_buffer, return_data[..check_buffer.len()]); + // we're sure that we're getting the full data, so also compare the deserialized + // type + if start.is_none() && end.is_none() { + let emitted_token_metadata = + try_from_slice_unchecked::(&return_data).unwrap(); + assert_eq!(token_metadata, emitted_token_metadata); + } + } else { + assert!(simulation.simulation_details.unwrap().return_data.is_none()); + } + } else { + assert!(simulation.simulation_details.unwrap().return_data.is_none()); + } +} diff --git a/program/tests/initialize.rs b/program/tests/initialize.rs new file mode 100644 index 0000000..2dc28ef --- /dev/null +++ b/program/tests/initialize.rs @@ -0,0 +1,271 @@ +#![cfg(feature = "test-sbf")] + +mod program_test; +use { + program_test::{setup, setup_metadata, setup_mint}, + solana_program_test::tokio, + solana_sdk::{ + instruction::InstructionError, + pubkey::Pubkey, + signature::Signer, + signer::keypair::Keypair, + system_instruction, + transaction::{Transaction, TransactionError}, + }, + spl_token_metadata_interface::{ + error::TokenMetadataError, instruction::initialize, state::TokenMetadata, + }, + spl_type_length_value::{ + error::TlvError, + state::{TlvState, TlvStateBorrowed}, + }, +}; + +#[tokio::test] +async fn success_initialize() { + let program_id = Pubkey::new_unique(); + let (context, client, payer) = setup(&program_id).await; + + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + + let token_program_id = spl_token_2022::id(); + let decimals = 2; + let token = setup_mint( + &token_program_id, + &mint_authority_pubkey, + decimals, + payer.clone(), + client.clone(), + ) + .await; + let mut context = context.lock().await; + + let update_authority = Pubkey::new_unique(); + let name = "MySuperCoolToken".to_string(); + let symbol = "MINE".to_string(); + let uri = "my.super.cool.token".to_string(); + let token_metadata = TokenMetadata { + name, + symbol, + uri, + update_authority: Some(update_authority).try_into().unwrap(), + mint: *token.get_address(), + ..Default::default() + }; + + let metadata_keypair = Keypair::new(); + let metadata_pubkey = metadata_keypair.pubkey(); + + setup_metadata( + &mut context, + &program_id, + token.get_address(), + &token_metadata, + &metadata_keypair, + &mint_authority, + ) + .await; + + // check that the data is correct + let fetched_metadata_account = context + .banks_client + .get_account(metadata_pubkey) + .await + .unwrap() + .unwrap(); + let fetched_metadata_state = TlvStateBorrowed::unpack(&fetched_metadata_account.data).unwrap(); + let fetched_metadata = fetched_metadata_state + .get_first_variable_len_value::() + .unwrap(); + assert_eq!(fetched_metadata, token_metadata); + + // fail doing it again, and reverse some params to ensure a new tx + { + let transaction = Transaction::new_signed_with_payer( + &[initialize( + &program_id, + &metadata_pubkey, + &update_authority, + token.get_address(), + &mint_authority_pubkey, + token_metadata.symbol.clone(), // intentionally reversed! + token_metadata.name.clone(), + token_metadata.uri.clone(), + )], + Some(&payer.pubkey()), + &[&payer, &mint_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(TlvError::TypeAlreadyExists as u32) + ) + ); + } +} + +#[tokio::test] +async fn fail_without_authority_signature() { + let program_id = Pubkey::new_unique(); + let (context, client, payer) = setup(&program_id).await; + + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + + let token_program_id = spl_token_2022::id(); + let decimals = 2; + let token = setup_mint( + &token_program_id, + &mint_authority_pubkey, + decimals, + payer.clone(), + client.clone(), + ) + .await; + let context = context.lock().await; + + let update_authority = Pubkey::new_unique(); + let name = "MySuperCoolToken".to_string(); + let symbol = "MINE".to_string(); + let uri = "my.super.cool.token".to_string(); + let token_metadata = TokenMetadata { + name, + symbol, + uri, + update_authority: Some(update_authority).try_into().unwrap(), + mint: *token.get_address(), + ..Default::default() + }; + + let metadata_keypair = Keypair::new(); + let metadata_pubkey = metadata_keypair.pubkey(); + let rent = context.banks_client.get_rent().await.unwrap(); + let space = token_metadata.tlv_size_of().unwrap(); + let rent_lamports = rent.minimum_balance(space); + let mut initialize_ix = initialize( + &program_id, + &metadata_pubkey, + &update_authority, + token.get_address(), + &mint_authority_pubkey, + token_metadata.name.clone(), + token_metadata.symbol.clone(), + token_metadata.uri.clone(), + ); + initialize_ix.accounts[3].is_signer = false; + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::create_account( + &payer.pubkey(), + &metadata_pubkey, + rent_lamports, + space.try_into().unwrap(), + &program_id, + ), + initialize_ix, + ], + Some(&payer.pubkey()), + &[&payer, &metadata_keypair], + context.last_blockhash, + ); + + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError(1, InstructionError::MissingRequiredSignature,) + ); +} + +#[tokio::test] +async fn fail_incorrect_authority() { + let program_id = Pubkey::new_unique(); + let (context, client, payer) = setup(&program_id).await; + + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + + let token_program_id = spl_token_2022::id(); + let decimals = 2; + let token = setup_mint( + &token_program_id, + &mint_authority_pubkey, + decimals, + payer.clone(), + client.clone(), + ) + .await; + let context = context.lock().await; + + let update_authority = Pubkey::new_unique(); + let name = "MySuperCoolToken".to_string(); + let symbol = "MINE".to_string(); + let uri = "my.super.cool.token".to_string(); + let token_metadata = TokenMetadata { + name, + symbol, + uri, + update_authority: Some(update_authority).try_into().unwrap(), + mint: *token.get_address(), + ..Default::default() + }; + + let metadata_keypair = Keypair::new(); + let metadata_pubkey = metadata_keypair.pubkey(); + let rent = context.banks_client.get_rent().await.unwrap(); + let space = token_metadata.tlv_size_of().unwrap(); + let rent_lamports = rent.minimum_balance(space); + let mut initialize_ix = initialize( + &program_id, + &metadata_pubkey, + &update_authority, + token.get_address(), + &metadata_pubkey, + token_metadata.name.clone(), + token_metadata.symbol.clone(), + token_metadata.uri.clone(), + ); + initialize_ix.accounts[3].is_signer = false; + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::create_account( + &payer.pubkey(), + &metadata_pubkey, + rent_lamports, + space.try_into().unwrap(), + &program_id, + ), + initialize_ix, + ], + Some(&payer.pubkey()), + &[&payer, &metadata_keypair], + context.last_blockhash, + ); + + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 1, + InstructionError::Custom(TokenMetadataError::IncorrectMintAuthority as u32) + ) + ); +} diff --git a/program/tests/program_test.rs b/program/tests/program_test.rs new file mode 100644 index 0000000..19e91ee --- /dev/null +++ b/program/tests/program_test.rs @@ -0,0 +1,167 @@ +#![cfg(feature = "test-sbf")] + +use { + solana_program_test::{processor, tokio::sync::Mutex, ProgramTest, ProgramTestContext}, + solana_sdk::{ + pubkey::Pubkey, signature::Signer, signer::keypair::Keypair, system_instruction, + transaction::Transaction, + }, + spl_token_client::{ + client::{ + ProgramBanksClient, ProgramBanksClientProcessTransaction, ProgramClient, + SendTransaction, SimulateTransaction, + }, + token::Token, + }, + spl_token_metadata_interface::{ + instruction::{initialize, update_field}, + state::{Field, TokenMetadata}, + }, + std::sync::Arc, +}; + +fn keypair_clone(kp: &Keypair) -> Keypair { + Keypair::from_bytes(&kp.to_bytes()).expect("failed to copy keypair") +} + +pub async fn setup( + program_id: &Pubkey, +) -> ( + Arc>, + Arc>, + Arc, +) { + let mut program_test = ProgramTest::new( + "spl_token_metadata_example", + *program_id, + processor!(spl_token_metadata_example::processor::process), + ); + + program_test.prefer_bpf(false); // simplicity in the build + program_test.add_program( + "spl_token_2022", + spl_token_2022::id(), + processor!(spl_token_2022::processor::Processor::process), + ); + + let context = program_test.start_with_context().await; + let payer = Arc::new(keypair_clone(&context.payer)); + let context = Arc::new(Mutex::new(context)); + + let client: Arc> = + Arc::new(ProgramBanksClient::new_from_context( + Arc::clone(&context), + ProgramBanksClientProcessTransaction, + )); + (context, client, payer) +} + +pub async fn setup_mint( + program_id: &Pubkey, + mint_authority: &Pubkey, + decimals: u8, + payer: Arc, + client: Arc>, +) -> Token { + let mint_account = Keypair::new(); + let token = Token::new( + client, + program_id, + &mint_account.pubkey(), + Some(decimals), + payer, + ); + token + .create_mint(mint_authority, None, vec![], &[&mint_account]) + .await + .unwrap(); + token +} + +pub async fn setup_metadata( + context: &mut ProgramTestContext, + metadata_program_id: &Pubkey, + mint: &Pubkey, + token_metadata: &TokenMetadata, + metadata_keypair: &Keypair, + mint_authority: &Keypair, +) { + let rent = context.banks_client.get_rent().await.unwrap(); + let space = token_metadata.tlv_size_of().unwrap(); + let rent_lamports = rent.minimum_balance(space); + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::create_account( + &context.payer.pubkey(), + &metadata_keypair.pubkey(), + rent_lamports, + space.try_into().unwrap(), + metadata_program_id, + ), + initialize( + metadata_program_id, + &metadata_keypair.pubkey(), + &Option::::from(token_metadata.update_authority).unwrap(), + mint, + &mint_authority.pubkey(), + token_metadata.name.clone(), + token_metadata.symbol.clone(), + token_metadata.uri.clone(), + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, metadata_keypair, mint_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); +} + +#[allow(dead_code)] +pub async fn setup_update_field( + context: &mut ProgramTestContext, + metadata_program_id: &Pubkey, + token_metadata: &mut TokenMetadata, + metadata: &Pubkey, + update_authority: &Keypair, + field: Field, + value: String, +) { + let rent = context.banks_client.get_rent().await.unwrap(); + let old_space = token_metadata.tlv_size_of().unwrap(); + let old_rent_lamports = rent.minimum_balance(old_space); + + token_metadata.update(field.clone(), value.clone()); + + let new_space = token_metadata.tlv_size_of().unwrap(); + let new_rent_lamports = rent.minimum_balance(new_space); + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + metadata, + new_rent_lamports.saturating_sub(old_rent_lamports), + ), + update_field( + metadata_program_id, + metadata, + &update_authority.pubkey(), + field, + value, + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, update_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); +} diff --git a/program/tests/remove_key.rs b/program/tests/remove_key.rs new file mode 100644 index 0000000..505113f --- /dev/null +++ b/program/tests/remove_key.rs @@ -0,0 +1,271 @@ +#![cfg(feature = "test-sbf")] + +mod program_test; +use { + program_test::{setup, setup_metadata, setup_mint, setup_update_field}, + solana_program_test::{tokio, ProgramTestBanksClientExt}, + solana_sdk::{ + instruction::InstructionError, + pubkey::Pubkey, + signature::Signer, + signer::keypair::Keypair, + transaction::{Transaction, TransactionError}, + }, + spl_token_metadata_interface::{ + error::TokenMetadataError, + instruction::remove_key, + state::{Field, TokenMetadata}, + }, + spl_type_length_value::state::{TlvState, TlvStateBorrowed}, +}; + +#[tokio::test] +async fn success_remove() { + let program_id = Pubkey::new_unique(); + let (context, client, payer) = setup(&program_id).await; + + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + + let token_program_id = spl_token_2022::id(); + let decimals = 2; + let token = setup_mint( + &token_program_id, + &mint_authority_pubkey, + decimals, + payer.clone(), + client.clone(), + ) + .await; + let mut context = context.lock().await; + + let update_authority = Keypair::new(); + let name = "MySuperCoolToken".to_string(); + let symbol = "MINE".to_string(); + let uri = "my.super.cool.token".to_string(); + let mut token_metadata = TokenMetadata { + name, + symbol, + uri, + update_authority: Some(update_authority.pubkey()).try_into().unwrap(), + mint: *token.get_address(), + ..Default::default() + }; + + let metadata_keypair = Keypair::new(); + let metadata_pubkey = metadata_keypair.pubkey(); + + setup_metadata( + &mut context, + &program_id, + token.get_address(), + &token_metadata, + &metadata_keypair, + &mint_authority, + ) + .await; + + let key = "key".to_string(); + let value = "value".to_string(); + let field = Field::Key(key.clone()); + setup_update_field( + &mut context, + &program_id, + &mut token_metadata, + &metadata_pubkey, + &update_authority, + field, + value, + ) + .await; + + let transaction = Transaction::new_signed_with_payer( + &[remove_key( + &program_id, + &metadata_pubkey, + &update_authority.pubkey(), + key.clone(), + false, // idempotent + )], + Some(&payer.pubkey()), + &[&payer, &update_authority], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // check that the data is correct + token_metadata.remove_key(&key); + let fetched_metadata_account = context + .banks_client + .get_account(metadata_pubkey) + .await + .unwrap() + .unwrap(); + assert_eq!( + fetched_metadata_account.data.len(), + token_metadata.tlv_size_of().unwrap() + ); + let fetched_metadata_state = TlvStateBorrowed::unpack(&fetched_metadata_account.data).unwrap(); + let fetched_metadata = fetched_metadata_state + .get_first_variable_len_value::() + .unwrap(); + assert_eq!(fetched_metadata, token_metadata); + + // refresh blockhash before trying again + let last_blockhash = context.last_blockhash; + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + // fail doing it again without idempotent flag + let transaction = Transaction::new_signed_with_payer( + &[remove_key( + &program_id, + &metadata_pubkey, + &update_authority.pubkey(), + key.clone(), + false, // idempotent + )], + Some(&payer.pubkey()), + &[&payer, &update_authority], + last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenMetadataError::KeyNotFound as u32) + ) + ); + + // succeed with idempotent flag + let transaction = Transaction::new_signed_with_payer( + &[remove_key( + &program_id, + &metadata_pubkey, + &update_authority.pubkey(), + key, + true, // idempotent + )], + Some(&payer.pubkey()), + &[&payer, &update_authority], + last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); +} + +#[tokio::test] +async fn fail_authority_checks() { + let program_id = Pubkey::new_unique(); + let (context, client, payer) = setup(&program_id).await; + + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + + let token_program_id = spl_token_2022::id(); + let decimals = 2; + let token = setup_mint( + &token_program_id, + &mint_authority_pubkey, + decimals, + payer.clone(), + client.clone(), + ) + .await; + let mut context = context.lock().await; + + let update_authority = Keypair::new(); + let name = "MySuperCoolToken".to_string(); + let symbol = "MINE".to_string(); + let uri = "my.super.cool.token".to_string(); + let token_metadata = TokenMetadata { + name, + symbol, + uri, + update_authority: Some(update_authority.pubkey()).try_into().unwrap(), + mint: *token.get_address(), + ..Default::default() + }; + + let metadata_keypair = Keypair::new(); + let metadata_pubkey = metadata_keypair.pubkey(); + + setup_metadata( + &mut context, + &program_id, + token.get_address(), + &token_metadata, + &metadata_keypair, + &mint_authority, + ) + .await; + + // no signature + let mut instruction = remove_key( + &program_id, + &metadata_pubkey, + &update_authority.pubkey(), + "new_name".to_string(), + true, // idempotent + ); + instruction.accounts[1].is_signer = false; + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&payer.pubkey()), + &[payer.as_ref()], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError(0, InstructionError::MissingRequiredSignature,) + ); + + // wrong authority + let transaction = Transaction::new_signed_with_payer( + &[remove_key( + &program_id, + &metadata_pubkey, + &payer.pubkey(), + "new_name".to_string(), + true, // idempotent + )], + Some(&payer.pubkey()), + &[payer.as_ref()], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenMetadataError::IncorrectUpdateAuthority as u32), + ) + ); +} diff --git a/program/tests/update_authority.rs b/program/tests/update_authority.rs new file mode 100644 index 0000000..cb2251b --- /dev/null +++ b/program/tests/update_authority.rs @@ -0,0 +1,264 @@ +#![cfg(feature = "test-sbf")] + +mod program_test; +use { + program_test::{setup, setup_metadata, setup_mint}, + solana_program_test::tokio, + solana_sdk::{ + instruction::InstructionError, + pubkey::Pubkey, + signature::Signer, + signer::keypair::Keypair, + transaction::{Transaction, TransactionError}, + }, + spl_pod::optional_keys::OptionalNonZeroPubkey, + spl_token_metadata_interface::{ + error::TokenMetadataError, instruction::update_authority, state::TokenMetadata, + }, + spl_type_length_value::state::{TlvState, TlvStateBorrowed}, +}; + +#[tokio::test] +async fn success_update() { + let program_id = Pubkey::new_unique(); + let (context, client, payer) = setup(&program_id).await; + + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + + let token_program_id = spl_token_2022::id(); + let decimals = 2; + let token = setup_mint( + &token_program_id, + &mint_authority_pubkey, + decimals, + payer.clone(), + client.clone(), + ) + .await; + let mut context = context.lock().await; + + let authority = Keypair::new(); + let name = "MySuperCoolToken".to_string(); + let symbol = "MINE".to_string(); + let uri = "my.super.cool.token".to_string(); + let mut token_metadata = TokenMetadata { + name, + symbol, + uri, + update_authority: Some(authority.pubkey()).try_into().unwrap(), + mint: *token.get_address(), + ..Default::default() + }; + + let metadata_keypair = Keypair::new(); + let metadata_pubkey = metadata_keypair.pubkey(); + + setup_metadata( + &mut context, + &program_id, + token.get_address(), + &token_metadata, + &metadata_keypair, + &mint_authority, + ) + .await; + + let new_update_authority = Keypair::new(); + let new_update_authority_pubkey = + OptionalNonZeroPubkey::try_from(Some(new_update_authority.pubkey())).unwrap(); + token_metadata.update_authority = new_update_authority_pubkey; + + let transaction = Transaction::new_signed_with_payer( + &[update_authority( + &program_id, + &metadata_pubkey, + &authority.pubkey(), + new_update_authority_pubkey, + )], + Some(&payer.pubkey()), + &[&payer, &authority], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // check that the data is correct + let fetched_metadata_account = context + .banks_client + .get_account(metadata_pubkey) + .await + .unwrap() + .unwrap(); + assert_eq!( + fetched_metadata_account.data.len(), + token_metadata.tlv_size_of().unwrap() + ); + let fetched_metadata_state = TlvStateBorrowed::unpack(&fetched_metadata_account.data).unwrap(); + let fetched_metadata = fetched_metadata_state + .get_first_variable_len_value::() + .unwrap(); + assert_eq!(fetched_metadata, token_metadata); + + // unset + token_metadata.update_authority = None.try_into().unwrap(); + let transaction = Transaction::new_signed_with_payer( + &[update_authority( + &program_id, + &metadata_pubkey, + &new_update_authority.pubkey(), + None.try_into().unwrap(), + )], + Some(&payer.pubkey()), + &[&payer, &new_update_authority], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let fetched_metadata_account = context + .banks_client + .get_account(metadata_pubkey) + .await + .unwrap() + .unwrap(); + assert_eq!( + fetched_metadata_account.data.len(), + token_metadata.tlv_size_of().unwrap() + ); + let fetched_metadata_state = TlvStateBorrowed::unpack(&fetched_metadata_account.data).unwrap(); + let fetched_metadata = fetched_metadata_state + .get_first_variable_len_value::() + .unwrap(); + assert_eq!(fetched_metadata, token_metadata); + + // fail to update + let transaction = Transaction::new_signed_with_payer( + &[update_authority( + &program_id, + &metadata_pubkey, + &new_update_authority.pubkey(), + Some(new_update_authority.pubkey()).try_into().unwrap(), + )], + Some(&payer.pubkey()), + &[&payer, &new_update_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenMetadataError::ImmutableMetadata as u32) + ) + ); +} + +#[tokio::test] +async fn fail_authority_checks() { + let program_id = Pubkey::new_unique(); + let (context, client, payer) = setup(&program_id).await; + + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + + let token_program_id = spl_token_2022::id(); + let decimals = 2; + let token = setup_mint( + &token_program_id, + &mint_authority_pubkey, + decimals, + payer.clone(), + client.clone(), + ) + .await; + let mut context = context.lock().await; + + let authority = Keypair::new(); + let name = "MySuperCoolToken".to_string(); + let symbol = "MINE".to_string(); + let uri = "my.super.cool.token".to_string(); + let token_metadata = TokenMetadata { + name, + symbol, + uri, + update_authority: Some(authority.pubkey()).try_into().unwrap(), + mint: *token.get_address(), + ..Default::default() + }; + + let metadata_keypair = Keypair::new(); + let metadata_pubkey = metadata_keypair.pubkey(); + + setup_metadata( + &mut context, + &program_id, + token.get_address(), + &token_metadata, + &metadata_keypair, + &mint_authority, + ) + .await; + + // no signature + let mut instruction = update_authority( + &program_id, + &metadata_pubkey, + &authority.pubkey(), + None.try_into().unwrap(), + ); + instruction.accounts[1].is_signer = false; + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&payer.pubkey()), + &[payer.as_ref()], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError(0, InstructionError::MissingRequiredSignature,) + ); + + // wrong authority + let transaction = Transaction::new_signed_with_payer( + &[update_authority( + &program_id, + &metadata_pubkey, + &payer.pubkey(), + None.try_into().unwrap(), + )], + Some(&payer.pubkey()), + &[payer.as_ref()], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenMetadataError::IncorrectUpdateAuthority as u32), + ) + ); +} diff --git a/program/tests/update_field.rs b/program/tests/update_field.rs new file mode 100644 index 0000000..d44356d --- /dev/null +++ b/program/tests/update_field.rs @@ -0,0 +1,251 @@ +#![cfg(feature = "test-sbf")] +#![allow(clippy::items_after_test_module)] + +mod program_test; +use { + program_test::{setup, setup_metadata, setup_mint}, + solana_program_test::tokio, + solana_sdk::{ + instruction::InstructionError, + pubkey::Pubkey, + signature::Signer, + signer::keypair::Keypair, + system_instruction, + transaction::{Transaction, TransactionError}, + }, + spl_token_metadata_interface::{ + error::TokenMetadataError, + instruction::update_field, + state::{Field, TokenMetadata}, + }, + spl_type_length_value::state::{TlvState, TlvStateBorrowed}, + test_case::test_case, +}; + +#[test_case(Field::Name, "This is my larger name".to_string() ; "larger name")] +#[test_case(Field::Name, "Smaller".to_string() ; "smaller name")] +#[test_case(Field::Key("my new field".to_string()), "Some data for the new field!".to_string() ; "new field")] +#[tokio::test] +async fn success_update(field: Field, value: String) { + let program_id = Pubkey::new_unique(); + let (context, client, payer) = setup(&program_id).await; + + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + + let token_program_id = spl_token_2022::id(); + let decimals = 2; + let token = setup_mint( + &token_program_id, + &mint_authority_pubkey, + decimals, + payer.clone(), + client.clone(), + ) + .await; + let mut context = context.lock().await; + + let update_authority = Keypair::new(); + let name = "MySuperCoolToken".to_string(); + let symbol = "MINE".to_string(); + let uri = "my.super.cool.token".to_string(); + let mut token_metadata = TokenMetadata { + name, + symbol, + uri, + update_authority: Some(update_authority.pubkey()).try_into().unwrap(), + mint: *token.get_address(), + ..Default::default() + }; + + let metadata_keypair = Keypair::new(); + let metadata_pubkey = metadata_keypair.pubkey(); + + setup_metadata( + &mut context, + &program_id, + token.get_address(), + &token_metadata, + &metadata_keypair, + &mint_authority, + ) + .await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let old_space = token_metadata.tlv_size_of().unwrap(); + let old_rent_lamports = rent.minimum_balance(old_space); + + token_metadata.update(field.clone(), value.clone()); + + let new_space = token_metadata.tlv_size_of().unwrap(); + + if new_space > old_space { + // fails without more lamports + let transaction = Transaction::new_signed_with_payer( + &[update_field( + &program_id, + &metadata_pubkey, + &update_authority.pubkey(), + field.clone(), + value.clone(), + )], + Some(&payer.pubkey()), + &[&payer, &update_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InsufficientFundsForRent { account_index: 2 } + ); + } + + // transfer required lamports + let new_rent_lamports = rent.minimum_balance(new_space); + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &payer.pubkey(), + &metadata_pubkey, + new_rent_lamports.saturating_sub(old_rent_lamports), + ), + update_field( + &program_id, + &metadata_pubkey, + &update_authority.pubkey(), + field.clone(), + value.clone(), + ), + ], + Some(&payer.pubkey()), + &[&payer, &update_authority], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // check that the data is correct + let fetched_metadata_account = context + .banks_client + .get_account(metadata_pubkey) + .await + .unwrap() + .unwrap(); + assert_eq!( + fetched_metadata_account.data.len(), + token_metadata.tlv_size_of().unwrap() + ); + let fetched_metadata_state = TlvStateBorrowed::unpack(&fetched_metadata_account.data).unwrap(); + let fetched_metadata = fetched_metadata_state + .get_first_variable_len_value::() + .unwrap(); + assert_eq!(fetched_metadata, token_metadata); +} + +#[tokio::test] +async fn fail_authority_checks() { + let program_id = Pubkey::new_unique(); + let (context, client, payer) = setup(&program_id).await; + + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + + let token_program_id = spl_token_2022::id(); + let decimals = 2; + let token = setup_mint( + &token_program_id, + &mint_authority_pubkey, + decimals, + payer.clone(), + client.clone(), + ) + .await; + let mut context = context.lock().await; + + let update_authority = Keypair::new(); + let name = "MySuperCoolToken".to_string(); + let symbol = "MINE".to_string(); + let uri = "my.super.cool.token".to_string(); + let token_metadata = TokenMetadata { + name, + symbol, + uri, + update_authority: Some(update_authority.pubkey()).try_into().unwrap(), + mint: *token.get_address(), + ..Default::default() + }; + + let metadata_keypair = Keypair::new(); + let metadata_pubkey = metadata_keypair.pubkey(); + + setup_metadata( + &mut context, + &program_id, + token.get_address(), + &token_metadata, + &metadata_keypair, + &mint_authority, + ) + .await; + + // no signature + let mut instruction = update_field( + &program_id, + &metadata_pubkey, + &update_authority.pubkey(), + Field::Name, + "new_name".to_string(), + ); + instruction.accounts[1].is_signer = false; + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&payer.pubkey()), + &[payer.as_ref()], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError(0, InstructionError::MissingRequiredSignature,) + ); + + // wrong authority + let transaction = Transaction::new_signed_with_payer( + &[update_field( + &program_id, + &metadata_pubkey, + &payer.pubkey(), + Field::Name, + "new_name".to_string(), + )], + Some(&payer.pubkey()), + &[payer.as_ref()], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenMetadataError::IncorrectUpdateAuthority as u32), + ) + ); +}