diff --git a/clients/js/package.json b/clients/js/package.json index 3b96bf5..bd57176 100644 --- a/clients/js/package.json +++ b/clients/js/package.json @@ -51,9 +51,9 @@ "@iarna/toml": "^2.2.5", "@solana-program/compute-budget": "^0.7.0", "@solana-program/system": "^0.7.0", - "chalk": "^4.1.0", "commander": "^13.0.0", "pako": "^2.1.0", + "picocolors": "^1.1.1", "yaml": "^2.7.0" }, "devDependencies": { diff --git a/clients/js/pnpm-lock.yaml b/clients/js/pnpm-lock.yaml index 0893f6d..4fe4fff 100644 --- a/clients/js/pnpm-lock.yaml +++ b/clients/js/pnpm-lock.yaml @@ -17,15 +17,15 @@ importers: '@solana-program/system': specifier: ^0.7.0 version: 0.7.0(@solana/kit@2.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.7.3)(ws@8.18.0)) - chalk: - specifier: ^4.1.0 - version: 4.1.2 commander: specifier: ^13.0.0 version: 13.0.0 pako: specifier: ^2.1.0 version: 2.1.0 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 yaml: specifier: ^2.7.0 version: 2.7.0 diff --git a/clients/js/src/cli.ts b/clients/js/src/cli.ts deleted file mode 100644 index 6e8ca71..0000000 --- a/clients/js/src/cli.ts +++ /dev/null @@ -1,727 +0,0 @@ -#!/usr/bin/env node - -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -import { - Address, - address, - Commitment, - createKeyPairSignerFromBytes, - createSolanaRpc, - createSolanaRpcSubscriptions, - getBase58Decoder, - getBase64Decoder, - getTransactionEncoder, - isSolanaError, - KeyPairSigner, - MicroLamports, - Rpc, - RpcSubscriptions, - SolanaRpcApi, - SolanaRpcSubscriptionsApi, - Transaction, -} from '@solana/kit'; -import chalk from 'chalk'; -import { Command, Option } from 'commander'; -import { parse as parseYaml } from 'yaml'; -import { downloadMetadata } from './downloadMetadata'; -import { - Compression, - Encoding, - Format, - getCloseInstruction, - getSetAuthorityInstruction, - getSetImmutableInstruction, -} from './generated'; -import { - createDefaultTransactionPlanExecutor, - createDefaultTransactionPlanner, - InstructionPlan, - sequentialInstructionPlan, - TransactionPlanExecutor, - TransactionPlanner, - TransactionPlanResult, -} from './instructionPlans'; -import { getPdaDetails } from './internals'; -import { - packDirectData, - PackedData, - packExternalData, - packUrlData, -} from './packData'; -import { uploadMetadata } from './uploadMetadata'; -import { getProgramAuthority } from './utils'; - -const LOCALHOST_URL = 'http://127.0.0.1:8899'; -const LOCALHOST_WEBSOCKET_URL = 'ws://127.0.0.1:8900'; - -// Define the CLI program. -type GlobalOptions = { - keypair?: string; - payer?: string; - rpc?: string; - priorityFees?: MicroLamports; -}; -const program = new Command(); -program - .name('program-metadata') - .description('CLI to manage Solana program metadata and IDLs') - .version(__VERSION__) - .option( - '-k, --keypair ', - 'Path to keypair file. (default: solana config)' - ) - .option( - '-p, --payer ', - 'Path to keypair file of transaction fee and storage payer. (default: keypair)' - ) - .option('--rpc ', 'RPC URL. (default: solana config or localhost)') - .addOption( - new Option( - '--priority-fees ', - 'Priority fees per compute unit for sending transactions' - ) - .default('100000') - .argParser((value: string | undefined) => - value !== undefined ? (BigInt(value) as MicroLamports) : undefined - ) - ); - -// Upload metadata command. -type UploadOptions = GlobalOptions & { - nonCanonical: boolean; - file?: string; - url?: string; - account?: string; - accountOffset?: string; - accountLength?: string; - format?: string; - encoding?: string; - compression?: string; - bufferOnly: boolean; -}; -program - .command('upload') - .argument('', 'Seed for the metadata account') - .argument( - '', - 'Program associated with the metadata account', - address - ) - .argument( - '[content]', - 'Direct content to upload. See options for other sources such as --file, --url and --account.' - ) - .description('Upload metadata') - .option( - '--non-canonical', - 'When provided, a non-canonical metadata account will be uploaded using the active keypair as the authority.', - false - ) - .option( - '--file ', - 'The path to the file to upload (creates a "direct" data source).' - ) - .option('--url ', 'The url to upload (creates a "url" data source).') - .option( - '--account
', - 'The account address to upload (creates an "external" data source).' - ) - .option( - '--account-offset ', - 'The offset in which the data start on the provided account. (default: 0)' - ) - .option( - '--account-length ', - 'The length of the data on the provided account. (default: the rest of the data)' - ) - .addOption( - new Option( - '--format ', - 'The format of the provided data. (default: the file extension or "none")' - ).choices(['none', 'json', 'yaml', 'toml']) - ) - .addOption( - new Option( - '--encoding ', - 'Describes how to encode the data. (default: "utf8")' - ).choices(['none', 'utf8', 'base58', 'base64']) - ) - .addOption( - new Option( - '--compression ', - 'Describes how to compress the data. (default: "zlib")' - ).choices(['none', 'gzip', 'zlib']) - ) - .option( - '--buffer-only', - 'Only create the buffer and export the transaction that sets the buffer.', - false - ) - .action( - async ( - seed: string, - program: Address, - content: string | undefined, - _, - cmd: Command - ) => { - const options = cmd.optsWithGlobals() as UploadOptions; - const client = await getClient(options); - const { authority: programAuthority } = await getProgramAuthority( - client.rpc, - program - ); - if ( - !options.nonCanonical && - client.authority.address !== programAuthority - ) { - logErrorAndExit( - 'You must be the program authority to upload a canonical metadata account. Use `--non-canonical` option to upload as a third party.' - ); - } - await uploadMetadata({ - ...client, - ...getPackedData(content, options), - payer: client.payer, - authority: client.authority, - program, - seed, - format: getFormat(options), - closeBuffer: true, - priorityFees: options.priorityFees, - }); - const exportTransaction = false; // TODO: Option - if (exportTransaction) { - const transactionBytes = getTransactionEncoder().encode( - {} as Transaction - ); - const base64EncodedTransaction = - getBase64Decoder().decode(transactionBytes); - const base58EncodedTransaction = - getBase58Decoder().decode(transactionBytes); - logSuccess( - `Buffer successfully created for program ${chalk.bold(program)} and seed "${chalk.bold(seed)}"!\n` + - `Use the following transaction data to apply the buffer:\n\n` + - `[base64]\n${base64EncodedTransaction}\n\n[base58]\n${base58EncodedTransaction}` - ); - } else { - logSuccess( - `Metadata uploaded successfully for program ${chalk.bold(program)} and seed "${chalk.bold(seed)}"!` - ); - } - } - ); - -// Download metadata command. -type DownloadOptions = GlobalOptions & { - output?: string; - nonCanonical?: string | true; -}; -program - .command('download') - .description('Download IDL to file') - .argument('', 'Seed for the metadata account') - .argument( - '', - 'Program associated with the metadata account', - address - ) - .option('-o, --output ', 'Path to save the IDL file') - .option( - '--non-canonical [address]', - 'When provided, a non-canonical metadata account will be downloaded using the provided address or the active keypair as the authority.', - false - ) - .action(async (seed: string, program: Address, _, cmd: Command) => { - const options = cmd.optsWithGlobals() as DownloadOptions; - const client = getReadonlyClient(options); - const authority = - options.nonCanonical === true - ? (await getKeyPairSigners(options, client.configs))[0].address - : options.nonCanonical - ? address(options.nonCanonical) - : undefined; - try { - const content = await downloadMetadata( - client.rpc, - program, - seed, - authority - ); - if (options.output) { - fs.mkdirSync(path.dirname(options.output), { recursive: true }); - fs.writeFileSync(options.output, content); - logSuccess(`Metadata content saved to ${chalk.bold(options.output)}`); - } else { - console.log(content); - } - } catch (error) { - if (isSolanaError(error)) logErrorAndExit(error.message); - throw error; - } - }); - -program - .command('set-authority') - .description( - 'Set or update an additional authority on canonical metadata accounts' - ) - .argument('', 'Seed for the metadata account') - .argument( - '', - 'Program associated with the metadata account', - address - ) - .argument('', 'The new authority to set', address) - .action( - async ( - seed: string, - program: Address, - newAuthority: string, - _, - cmd: Command - ) => { - const options = cmd.optsWithGlobals() as GlobalOptions; - const client = await getClient(options); - const { metadata, programData } = await getPdaDetails({ - rpc: client.rpc, - program, - authority: client.authority, - seed, - }); - await client.planAndExecute( - sequentialInstructionPlan([ - getSetAuthorityInstruction({ - account: metadata, - authority: client.authority, - newAuthority: address(newAuthority), - program, - programData, - }), - ]) - ); - logSuccess( - `Additional authority successfully set to ${chalk.bold(newAuthority)}` - ); - } - ); - -program - .command('remove-authority') - .argument('', 'Seed for the metadata account') - .argument( - '', - 'Program associated with the metadata account', - address - ) - .description('Remove the additional authority on canonical metadata accounts') - .action(async (seed: string, program: Address, _, cmd: Command) => { - const options = cmd.optsWithGlobals() as GlobalOptions; - const client = await getClient(options); - const { metadata, programData } = await getPdaDetails({ - rpc: client.rpc, - program, - authority: client.authority, - seed, - }); - await client.planAndExecute( - sequentialInstructionPlan([ - getSetAuthorityInstruction({ - account: metadata, - authority: client.authority, - newAuthority: null, - program, - programData, - }), - ]) - ); - logSuccess('Additional authority successfully removed'); - }); - -type SetImmutableOptions = GlobalOptions & { - nonCanonical: boolean; -}; -program - .command('set-immutable') - .description( - 'Make the metadata account immutable, preventing any further updates' - ) - .argument('', 'Seed for the metadata account') - .argument( - '', - 'Program associated with the metadata account', - address - ) - .option( - '--non-canonical', - 'When provided, a non-canonical metadata account will be updated using the active keypair as the authority.', - false - ) - .action(async (seed: string, program: Address, _, cmd: Command) => { - const options = cmd.optsWithGlobals() as SetImmutableOptions; - const client = await getClient(options); - const { authority: programAuthority } = await getProgramAuthority( - client.rpc, - program - ); - if ( - !options.nonCanonical && - client.authority.address !== programAuthority - ) { - logErrorAndExit( - 'You must be the program authority to update a canonical metadata account. Use `--non-canonical` option to update as a third party.' - ); - } - const { metadata, programData } = await getPdaDetails({ - rpc: client.rpc, - program, - authority: client.authority, - seed, - }); - await client.planAndExecute( - sequentialInstructionPlan([ - getSetImmutableInstruction({ - metadata, - authority: client.authority, - program, - programData, - }), - ]) - ); - logSuccess('Metadata account successfully set as immutable'); - }); - -type CloseOptions = GlobalOptions & { - nonCanonical: boolean; -}; -program - .command('close') - .description('Close metadata account and recover rent') - .argument('', 'Seed for the metadata account') - .argument( - '', - 'Program associated with the metadata account', - address - ) - .option( - '--non-canonical', - 'When provided, a non-canonical metadata account will be closed using the active keypair as the authority.', - false - ) - .action(async (seed: string, program: Address, _, cmd: Command) => { - const options = cmd.optsWithGlobals() as CloseOptions; - const client = await getClient(options); - const { authority: programAuthority } = await getProgramAuthority( - client.rpc, - program - ); - if ( - !options.nonCanonical && - client.authority.address !== programAuthority - ) { - logErrorAndExit( - 'You must be the program authority to close a canonical metadata account. Use `--non-canonical` option to close as a third party.' - ); - } - const { metadata, programData } = await getPdaDetails({ - rpc: client.rpc, - program, - authority: client.authority, - seed, - }); - await client.planAndExecute( - sequentialInstructionPlan([ - getCloseInstruction({ - account: metadata, - authority: client.authority, - program, - programData, - destination: client.payer.address, - }), - ]) - ); - logSuccess('Account successfully closed and rent recovered'); - }); - -program - .command('list-buffers') - .description('List all buffer accounts owned by an authority') - .action(async () => { - // TODO - }); - -program - .command('list') - .description('List all metadata accounts owned by an authority') - .action(async () => { - // TODO - }); - -type Client = ReadonlyClient & { - authority: KeyPairSigner; - executor: TransactionPlanExecutor; - payer: KeyPairSigner; - planAndExecute: ( - instructionPlan: InstructionPlan - ) => Promise; - planner: TransactionPlanner; -}; - -async function getClient(options: { - keypair?: string; - payer?: string; - priorityFees?: MicroLamports; - rpc?: string; -}): Promise { - const readonlyClient = getReadonlyClient(options); - const [authority, payer] = await getKeyPairSigners( - options, - readonlyClient.configs - ); - const planner = createDefaultTransactionPlanner({ - feePayer: payer, - computeUnitPrice: options.priorityFees, - }); - const executor = createDefaultTransactionPlanExecutor({ - rpc: readonlyClient.rpc, - rpcSubscriptions: readonlyClient.rpcSubscriptions, - parallelChunkSize: 5, - }); - const planAndExecute = async ( - instructionPlan: InstructionPlan - ): Promise => { - const transactionPlan = await planner(instructionPlan); - return await executor(transactionPlan); - }; - return { - ...readonlyClient, - authority, - executor, - payer, - planAndExecute, - planner, - }; -} - -type ReadonlyClient = { - configs: SolanaConfigs; - rpc: Rpc; - rpcSubscriptions: RpcSubscriptions; -}; - -function getReadonlyClient(options: { rpc?: string }): ReadonlyClient { - const configs = getSolanaConfigs(); - const rpcUrl = getRpcUrl(options, configs); - const rpcSubscriptionsUrl = getRpcSubscriptionsUrl(rpcUrl, configs); - return { - configs, - rpc: createSolanaRpc(rpcUrl), - rpcSubscriptions: createSolanaRpcSubscriptions(rpcSubscriptionsUrl), - }; -} - -function getRpcUrl(options: { rpc?: string }, configs: SolanaConfigs): string { - if (options.rpc) return options.rpc; - if (configs.json_rpc_url) return configs.json_rpc_url; - return LOCALHOST_URL; -} - -function getRpcSubscriptionsUrl( - rpcUrl: string, - configs: SolanaConfigs -): string { - if (configs.websocket_url) return configs.websocket_url; - if (rpcUrl === LOCALHOST_URL) return LOCALHOST_WEBSOCKET_URL; - return rpcUrl.replace(/^http/, 'ws'); -} - -type SolanaConfigs = { - json_rpc_url?: string; - websocket_url?: string; - keypair_path?: string; - commitment?: Commitment; -}; - -function getSolanaConfigs(): SolanaConfigs { - const path = getSolanaConfigPath(); - if (!fs.existsSync(path)) { - logWarning('Solana config file not found'); - return {}; - } - return parseYaml(fs.readFileSync(getSolanaConfigPath(), 'utf8')); -} - -function getSolanaConfigPath(): string { - return path.join(os.homedir(), '.config', 'solana', 'cli', 'config.yml'); -} - -async function getKeyPairSigners( - options: { keypair?: string; payer?: string }, - configs: SolanaConfigs -): Promise<[KeyPairSigner, KeyPairSigner]> { - const keypairPath = getKeyPairPath(options, configs); - const keypairPromise = getKeyPairSignerFromPath(keypairPath); - const payerPromise = options.payer - ? getKeyPairSignerFromPath(options.payer) - : keypairPromise; - return await Promise.all([keypairPromise, payerPromise]); -} - -function getKeyPairPath( - options: { keypair?: string }, - configs: SolanaConfigs -): string { - if (options.keypair) return options.keypair; - if (configs.keypair_path) return configs.keypair_path; - return path.join(os.homedir(), '.config', 'solana', 'id.json'); -} - -async function getKeyPairSignerFromPath( - keypairPath: string -): Promise { - if (!fs.existsSync(keypairPath)) { - logErrorAndExit(`Keypair file not found at: ${keypairPath}`); - } - const keypairString = fs.readFileSync(keypairPath, 'utf-8'); - const keypairData = new Uint8Array(JSON.parse(keypairString)); - return await createKeyPairSignerFromBytes(keypairData); -} - -function getCompression(options: { compression?: string }): Compression { - switch (options.compression) { - case 'none': - return Compression.None; - case 'gzip': - return Compression.Gzip; - case undefined: - case 'zlib': - return Compression.Zlib; - default: - logErrorAndExit(`Invalid compression option: ${options.compression}`); - } -} - -function getEncoding(options: { encoding?: string }): Encoding { - switch (options.encoding) { - case 'none': - return Encoding.None; - case undefined: - case 'utf8': - return Encoding.Utf8; - case 'base58': - return Encoding.Base58; - case 'base64': - return Encoding.Base64; - default: - logErrorAndExit(`Invalid encoding option: ${options.encoding}`); - } -} - -function getFormat(options: { format?: string; file?: string }): Format { - switch (options.format) { - case undefined: - return getFormatFromFile(options.file); - case 'none': - return Format.None; - case 'json': - return Format.Json; - case 'yaml': - return Format.Yaml; - case 'toml': - return Format.Toml; - default: - logErrorAndExit(`Invalid format option: ${options.format}`); - } -} - -function getFormatFromFile(file: string | undefined): Format { - if (!file) return Format.None; - const extension = path.extname(file); - switch (extension) { - case '.json': - return Format.Json; - case '.yaml': - case '.yml': - return Format.Yaml; - case '.toml': - return Format.Toml; - default: - return Format.None; - } -} - -function getPackedData( - content: string | undefined, - options: UploadOptions -): PackedData { - const compression = getCompression(options); - const encoding = getEncoding(options); - let packData: PackedData | null = null; - const assertSingleUse = () => { - if (packData) { - logErrorAndExit( - 'Multiple data sources provided. Use only one of: `[content]`, `--file `, `--url ` or `--account
` to provide data.' - ); - } - }; - - if (content) { - packData = packDirectData({ content, compression, encoding }); - } - if (options.file) { - assertSingleUse(); - if (!fs.existsSync(options.file)) { - logErrorAndExit(`File not found: ${options.file}`); - } - const fileContent = fs.readFileSync(options.file, 'utf-8'); - packData = packDirectData({ content: fileContent, compression, encoding }); - } - if (options.url) { - assertSingleUse(); - packData = packUrlData({ url: options.url, compression, encoding }); - } - if (options.account) { - assertSingleUse(); - packData = packExternalData({ - address: address(options.account), - offset: options.accountOffset - ? parseInt(options.accountOffset) - : undefined, - length: options.accountLength - ? parseInt(options.accountLength) - : undefined, - compression, - encoding, - }); - } - - if (!packData) { - logErrorAndExit( - 'No data provided. Use `[content]`, `--file `, `--url ` or `--account
` to provide data.' - ); - } - - return packData; -} - -function logSuccess(message: string): void { - console.warn(chalk.green(`[Success] `) + message); -} - -function logWarning(message: string): void { - console.warn(chalk.yellow(`[Warning] `) + message); -} - -function logError(message: string): void { - console.error(chalk.red(`[Error] `) + message); -} - -function logErrorAndExit(message: string): never { - logError(message); - process.exit(1); -} - -program.parse(); diff --git a/clients/js/src/cli/arguments.ts b/clients/js/src/cli/arguments.ts new file mode 100644 index 0000000..db8d15e --- /dev/null +++ b/clients/js/src/cli/arguments.ts @@ -0,0 +1,22 @@ +import { Argument } from 'commander'; +import { addressParser } from './parsers'; + +export const seedArgument = new Argument( + '', + 'Seed of the metadata account (e.g. "idl" for program IDLs).' +); + +export const programArgument = new Argument( + '', + 'Program associated with the metadata account.' +).argParser(addressParser('program')); + +export const fileArgument = new Argument( + '[file]', + 'Filepath of the data to upload (creates a "direct" data source). See options for other sources such as --text, --url and --account.' +); + +export const bufferArgument = new Argument( + '', + 'The address of the buffer account.' +).argParser(addressParser('buffer')); diff --git a/clients/js/src/cli/commands/close-buffer.ts b/clients/js/src/cli/commands/close-buffer.ts new file mode 100644 index 0000000..e4a13c2 --- /dev/null +++ b/clients/js/src/cli/commands/close-buffer.ts @@ -0,0 +1,48 @@ +import { Address } from '@solana/kit'; +import { fetchMaybeBuffer, getCloseInstruction } from '../../generated'; +import { sequentialInstructionPlan } from '../../instructionPlans'; +import { bufferArgument } from '../arguments'; +import { logCommand, logErrorAndExit } from '../logs'; +import { GlobalOptions } from '../options'; +import { addressParser } from '../parsers'; +import { CustomCommand, getClient } from '../utils'; + +export function setCloseBufferCommand(program: CustomCommand): void { + program + .command('close-buffer') + .description('Close an existing buffer account.') + .addArgument(bufferArgument) + .option( + '--recipient ', + 'Address receiving the storage fees for the closed account.', + addressParser('recipient') + ) + .action(doCloseBuffer); +} + +type Options = { recipient?: Address }; +export async function doCloseBuffer( + buffer: Address, + _: Options, + cmd: CustomCommand +) { + const options = cmd.optsWithGlobals() as GlobalOptions & Options; + const client = await getClient(options); + + logCommand(`Closing buffer...`, { buffer }); + const bufferAccount = await fetchMaybeBuffer(client.rpc, buffer); + + if (!bufferAccount.exists) { + logErrorAndExit(`Buffer account not found: "${buffer}"`); + } + + await client.planAndExecute( + sequentialInstructionPlan([ + getCloseInstruction({ + account: buffer, + authority: client.authority, + destination: options.recipient ?? client.payer.address, + }), + ]) + ); +} diff --git a/clients/js/src/cli/commands/close.ts b/clients/js/src/cli/commands/close.ts new file mode 100644 index 0000000..6e5ffa1 --- /dev/null +++ b/clients/js/src/cli/commands/close.ts @@ -0,0 +1,57 @@ +import { Address } from '@solana/kit'; +import { getCloseInstruction, Seed } from '../../generated'; +import { sequentialInstructionPlan } from '../../instructionPlans'; +import { programArgument, seedArgument } from '../arguments'; +import { + GlobalOptions, + NonCanonicalWriteOption, + nonCanonicalWriteOption, +} from '../options'; +import { CustomCommand, getClient, getPdaDetailsForWriting } from '../utils'; +import { logCommand } from '../logs'; + +export function setCloseCommand(program: CustomCommand): void { + program + .command('close') + .description('Close metadata account and recover rent.') + .addArgument(seedArgument) + .addArgument(programArgument) + .addOption(nonCanonicalWriteOption) + .action(doClose); +} + +type Options = NonCanonicalWriteOption; +async function doClose( + seed: Seed, + program: Address, + _: Options, + cmd: CustomCommand +) { + const options = cmd.optsWithGlobals() as GlobalOptions & Options; + const client = await getClient(options); + const { metadata, programData, isCanonical } = await getPdaDetailsForWriting( + client, + options, + program, + seed + ); + + logCommand(`Closing metadata account...`, { + metadata, + program, + seed, + authority: isCanonical ? undefined : client.authority.address, + }); + + await client.planAndExecute( + sequentialInstructionPlan([ + getCloseInstruction({ + account: metadata, + authority: client.authority, + program, + programData, + destination: client.payer.address, + }), + ]) + ); +} diff --git a/clients/js/src/cli/commands/create-buffer.ts b/clients/js/src/cli/commands/create-buffer.ts new file mode 100644 index 0000000..a4d5ffd --- /dev/null +++ b/clients/js/src/cli/commands/create-buffer.ts @@ -0,0 +1,52 @@ +import { generateKeyPairSigner } from '@solana/kit'; +import { getCreateBufferInstructionPlan } from '../../createBuffer'; +import { getAccountSize } from '../../utils'; +import { fileArgument } from '../arguments'; +import { GlobalOptions, setWriteOptions, WriteOptions } from '../options'; +import { CustomCommand, getClient, getWriteInput } from '../utils'; +import { logCommand } from '../logs'; + +export function setCreateBufferCommand(program: CustomCommand): void { + program + .command('create-buffer') + .description('Create a buffer account to use on metadata account later on.') + .addArgument(fileArgument) + .tap(setWriteOptions) + .action(doCreateBuffer); +} + +type Options = WriteOptions; +export async function doCreateBuffer( + file: string | undefined, + _: Options, + cmd: CustomCommand +) { + const options = cmd.optsWithGlobals() as GlobalOptions & Options; + const client = await getClient(options); + const [buffer, writeInput] = await Promise.all([ + generateKeyPairSigner(), + getWriteInput(client, file, options), + ]); + + logCommand(`Creating new buffer and setting authority...`, { + buffer: buffer.address, + authority: client.authority.address, + }); + + const data = writeInput.buffer?.data.data ?? writeInput.data; + const rent = await client.rpc + .getMinimumBalanceForRentExemption(getAccountSize(data.length)) + .send(); + + await client.planAndExecute( + getCreateBufferInstructionPlan({ + newBuffer: buffer, + authority: client.authority, + payer: client.payer, + rent, + sourceBuffer: writeInput.buffer, + closeSourceBuffer: writeInput.closeBuffer, + data, + }) + ); +} diff --git a/clients/js/src/cli/commands/create.ts b/clients/js/src/cli/commands/create.ts new file mode 100644 index 0000000..573a0e5 --- /dev/null +++ b/clients/js/src/cli/commands/create.ts @@ -0,0 +1,81 @@ +import { Address } from '@solana/kit'; +import picocolors from 'picocolors'; +import { getCreateMetadataInstructionPlan } from '../../createMetadata'; +import { fetchMaybeMetadata, Seed } from '../../generated'; +import { fileArgument, programArgument, seedArgument } from '../arguments'; +import { logCommand, logErrorAndExit } from '../logs'; +import { + GlobalOptions, + nonCanonicalWriteOption, + NonCanonicalWriteOption, + setWriteOptions, + WriteOptions, +} from '../options'; +import { + CustomCommand, + getClient, + getPdaDetailsForWriting, + getWriteInput, +} from '../utils'; + +export function setCreateCommand(program: CustomCommand): void { + program + .command('create') + .description('Create a metadata account for a given program.') + .addArgument(seedArgument) + .addArgument(programArgument) + .addArgument(fileArgument) + .tap(setWriteOptions) + .addOption(nonCanonicalWriteOption) + .action(doCreate); +} + +type Options = WriteOptions & NonCanonicalWriteOption; +export async function doCreate( + seed: Seed, + program: Address, + file: string | undefined, + _: Options, + cmd: CustomCommand +) { + const options = cmd.optsWithGlobals() as GlobalOptions & Options; + const client = await getClient(options); + const { metadata, programData, isCanonical } = await getPdaDetailsForWriting( + client, + options, + program, + seed + ); + + logCommand(`Creating new metadata account...`, { + metadata, + program, + seed, + authority: isCanonical ? undefined : client.authority.address, + }); + + const [metadataAccount, writeInput] = await Promise.all([ + fetchMaybeMetadata(client.rpc, metadata), + getWriteInput(client, file, options), + ]); + + if (metadataAccount.exists) { + logErrorAndExit( + `Metadata account ${picocolors.bold(metadataAccount.address)} already exists.` + ); + } + + await client.planAndExecute( + await getCreateMetadataInstructionPlan({ + ...client, + ...writeInput, + payer: client.payer, + authority: client.authority, + program, + programData, + seed, + metadata, + planner: client.planner, + }) + ); +} diff --git a/clients/js/src/cli/commands/fetch-buffer.ts b/clients/js/src/cli/commands/fetch-buffer.ts new file mode 100644 index 0000000..27fd65c --- /dev/null +++ b/clients/js/src/cli/commands/fetch-buffer.ts @@ -0,0 +1,61 @@ +import { Address } from '@solana/kit'; +import { Option } from 'commander'; +import picocolors from 'picocolors'; +import { Compression, Encoding, fetchMaybeBuffer } from '../../generated'; +import { unpackDirectData } from '../../packData'; +import { bufferArgument } from '../arguments'; +import { logErrorAndExit, logSuccess } from '../logs'; +import { + compressionOption, + CompressionOption, + encodingOption, + EncodingOption, + GlobalOptions, + outputOption, + OutputOption, +} from '../options'; +import { CustomCommand, getReadonlyClient, writeFile } from '../utils'; + +export function setFetchBufferCommand(program: CustomCommand): void { + program + .command('fetch-buffer') + .description('Fetch the content of a buffer account.') + .addArgument(bufferArgument) + .addOption(outputOption) + .addOption(compressionOption) + .addOption(encodingOption) + .addOption( + new Option('--raw', 'Output raw data in hexadecimal format.') + .default(false) + .implies({ compression: Compression.None, encoding: Encoding.None }) + ) + .action(doFetchBuffer); +} + +type Options = OutputOption & CompressionOption & EncodingOption; +export async function doFetchBuffer( + buffer: Address, + _: Options, + cmd: CustomCommand +) { + const options = cmd.optsWithGlobals() as GlobalOptions & Options; + const client = getReadonlyClient(options); + const bufferAccount = await fetchMaybeBuffer(client.rpc, buffer); + + if (!bufferAccount.exists) { + logErrorAndExit(`Buffer account not found: "${buffer}"`); + } + + const content = unpackDirectData({ + data: bufferAccount.data.data, + encoding: options.encoding, + compression: options.compression, + }); + + if (options.output) { + writeFile(options.output, content); + logSuccess(`Buffer content saved to ${picocolors.bold(options.output)}`); + } else { + console.log(content); + } +} diff --git a/clients/js/src/cli/commands/fetch.ts b/clients/js/src/cli/commands/fetch.ts new file mode 100644 index 0000000..90607b9 --- /dev/null +++ b/clients/js/src/cli/commands/fetch.ts @@ -0,0 +1,84 @@ +import { Address, isSolanaError } from '@solana/kit'; +import picocolors from 'picocolors'; +import { Option } from 'commander'; +import { + Compression, + Encoding, + fetchMetadataFromSeeds, + Seed, +} from '../../generated'; +import { unpackAndFetchData, unpackDirectData } from '../../packData'; +import { programArgument, seedArgument } from '../arguments'; +import { logErrorAndExit, logSuccess } from '../logs'; +import { + GlobalOptions, + nonCanonicalReadOption, + NonCanonicalReadOption, + outputOption, + OutputOption, +} from '../options'; +import { + CustomCommand, + getKeyPairSigners, + getReadonlyClient, + writeFile, +} from '../utils'; + +export function setFetchCommand(program: CustomCommand): void { + program + .command('fetch') + .description('Fetch the content of a metadata account for a given program.') + .addArgument(seedArgument) + .addArgument(programArgument) + .addOption(nonCanonicalReadOption) + .addOption(outputOption) + .addOption( + new Option('--raw', 'Output raw data in hexadecimal format.').default( + false + ) + ) + .action(doFetch); +} + +type Options = NonCanonicalReadOption & OutputOption & { raw: boolean }; +async function doFetch( + seed: Seed, + program: Address, + _: Options, + cmd: CustomCommand +) { + const options = cmd.optsWithGlobals() as GlobalOptions & Options; + const client = getReadonlyClient(options); + const authority = + options.nonCanonical === true + ? (await getKeyPairSigners(options, client.configs))[0].address + : options.nonCanonical + ? options.nonCanonical + : undefined; + try { + const metadataAccount = await fetchMetadataFromSeeds(client.rpc, { + program, + authority: authority ?? null, + seed, + }); + const content = options.raw + ? unpackDirectData({ + encoding: Encoding.None, + data: metadataAccount.data.data, + compression: Compression.None, + }) + : await unpackAndFetchData({ rpc: client.rpc, ...metadataAccount.data }); + + if (options.output) { + writeFile(options.output, content); + logSuccess( + `Metadata content saved to ${picocolors.bold(options.output)}` + ); + } else { + console.log(content); + } + } catch (error) { + if (isSolanaError(error)) logErrorAndExit(error.message); + throw error; + } +} diff --git a/clients/js/src/cli/commands/index.ts b/clients/js/src/cli/commands/index.ts new file mode 100644 index 0000000..71cd59e --- /dev/null +++ b/clients/js/src/cli/commands/index.ts @@ -0,0 +1,36 @@ +import { CustomCommand } from '../utils'; +import { setCloseCommand } from './close'; +import { setCloseBufferCommand } from './close-buffer'; +import { setCreateCommand } from './create'; +import { setCreateBufferCommand } from './create-buffer'; +import { setFetchCommand } from './fetch'; +import { setFetchBufferCommand } from './fetch-buffer'; +import { setListBuffersCommand } from './list-buffers'; +import { setRemoveAuthorityCommand } from './remove-authority'; +import { setSetAuthorityCommand } from './set-authority'; +import { setSetBufferAuthorityCommand } from './set-buffer-authority'; +import { setSetImmutableCommand } from './set-immutable'; +import { setUpdateCommand } from './update'; +import { setUpdateBufferCommand } from './update-buffer'; +import { setWriteCommand } from './write'; + +export function setCommands(program: CustomCommand): void { + program + // Metadata commands. + .tap(setWriteCommand) + .tap(setCreateCommand) + .tap(setUpdateCommand) + .tap(setFetchCommand) + .tap(setSetAuthorityCommand) + .tap(setRemoveAuthorityCommand) + .tap(setSetImmutableCommand) + .tap(setCloseCommand) + + // Buffer commands. + .tap(setCreateBufferCommand) + .tap(setUpdateBufferCommand) + .tap(setListBuffersCommand) + .tap(setFetchBufferCommand) + .tap(setSetBufferAuthorityCommand) + .tap(setCloseBufferCommand); +} diff --git a/clients/js/src/cli/commands/list-buffers.ts b/clients/js/src/cli/commands/list-buffers.ts new file mode 100644 index 0000000..d5a5224 --- /dev/null +++ b/clients/js/src/cli/commands/list-buffers.ts @@ -0,0 +1,98 @@ +import { + Address, + getAddressEncoder, + getBase64Decoder, + GetProgramAccountsMemcmpFilter, + pipe, +} from '@solana/kit'; +import { Argument } from 'commander'; +import { + AccountDiscriminator, + getAccountDiscriminatorEncoder, + PROGRAM_METADATA_PROGRAM_ADDRESS, +} from '../../generated'; +import { humanFileSize, logCommand, logTable } from '../logs'; +import { GlobalOptions } from '../options'; +import { addressParser } from '../parsers'; +import { CustomCommand, getKeyPairSigners, getReadonlyClient } from '../utils'; +import { ACCOUNT_HEADER_LENGTH } from '../../utils'; + +const DISCRIMINATOR_OFFSET = 0n; +const AUTHORITY_OFFSET = 33n; + +export function setListBuffersCommand(program: CustomCommand): void { + program + .command('list-buffers') + .description('List all buffer accounts owned by an authority.') + .addArgument( + new Argument('[authority]', 'Authority to list buffer accounts for.') + .default(undefined, 'keypair option') + .argParser(addressParser('authority')) + ) + .action(doListBuffers); +} + +type Options = {}; +export async function doListBuffers( + authorityArgument: Address | undefined, + _: Options, + cmd: CustomCommand +) { + const options = cmd.optsWithGlobals() as GlobalOptions & Options; + const client = getReadonlyClient(options); + const authority = + authorityArgument === undefined + ? (await getKeyPairSigners(options, client.configs))[0].address + : authorityArgument; + + logCommand(`Listing buffer accounts for...`, { authority }); + + const result = await client.rpc + .getProgramAccounts(PROGRAM_METADATA_PROGRAM_ADDRESS, { + dataSlice: { offset: 0, length: 0 }, + filters: [getDiscriminatorFilter(), getAuthorityFilter(authority)], + }) + .send(); + + if (result.length === 0) { + console.log('No buffer accounts found.'); + return; + } + + const content = result.map(({ pubkey, account }) => ({ + address: pubkey, + size: humanFileSize(Number(account.space) - ACCOUNT_HEADER_LENGTH), + })); + + logTable(content); +} + +function getDiscriminatorFilter(): GetProgramAccountsMemcmpFilter { + return { + memcmp: { + bytes: pipe( + AccountDiscriminator.Buffer, + getAccountDiscriminatorEncoder().encode, + getBase64Decoder().decode + ), + encoding: 'base64', + offset: DISCRIMINATOR_OFFSET, + }, + }; +} + +function getAuthorityFilter( + authority: Address +): GetProgramAccountsMemcmpFilter { + return { + memcmp: { + bytes: pipe( + authority, + getAddressEncoder().encode, + getBase64Decoder().decode + ), + encoding: 'base64', + offset: AUTHORITY_OFFSET, + }, + }; +} diff --git a/clients/js/src/cli/commands/remove-authority.ts b/clients/js/src/cli/commands/remove-authority.ts new file mode 100644 index 0000000..d20dbc7 --- /dev/null +++ b/clients/js/src/cli/commands/remove-authority.ts @@ -0,0 +1,54 @@ +import { Address } from '@solana/kit'; +import { getSetAuthorityInstruction, Seed } from '../../generated'; +import { sequentialInstructionPlan } from '../../instructionPlans'; +import { getPdaDetails } from '../../internals'; +import { programArgument, seedArgument } from '../arguments'; +import { GlobalOptions } from '../options'; +import { CustomCommand, getClient } from '../utils'; +import { logCommand } from '../logs'; + +export function setRemoveAuthorityCommand(program: CustomCommand): void { + program + .command('remove-authority') + .description( + 'Remove the additional authority on canonical metadata accounts.' + ) + .addArgument(seedArgument) + .addArgument(programArgument) + .action(doRemoveAuthority); +} + +type Options = {}; +async function doRemoveAuthority( + seed: Seed, + program: Address, + _: Options, + cmd: CustomCommand +) { + const options = cmd.optsWithGlobals() as GlobalOptions & Options; + const client = await getClient(options); + const { metadata, programData } = await getPdaDetails({ + rpc: client.rpc, + program, + authority: client.authority, + seed, + }); + + logCommand(`Removing additional authority from metadata account...`, { + metadata, + program, + seed, + }); + + await client.planAndExecute( + sequentialInstructionPlan([ + getSetAuthorityInstruction({ + account: metadata, + authority: client.authority, + newAuthority: null, + program, + programData, + }), + ]) + ); +} diff --git a/clients/js/src/cli/commands/set-authority.ts b/clients/js/src/cli/commands/set-authority.ts new file mode 100644 index 0000000..b4d011b --- /dev/null +++ b/clients/js/src/cli/commands/set-authority.ts @@ -0,0 +1,59 @@ +import { Address } from '@solana/kit'; +import { getSetAuthorityInstruction, Seed } from '../../generated'; +import { sequentialInstructionPlan } from '../../instructionPlans'; +import { getPdaDetails } from '../../internals'; +import { programArgument, seedArgument } from '../arguments'; +import { logCommand } from '../logs'; +import { + GlobalOptions, + NewAuthorityOption, + newAuthorityOption, +} from '../options'; +import { CustomCommand, getClient } from '../utils'; + +export function setSetAuthorityCommand(program: CustomCommand): void { + program + .command('set-authority') + .description( + 'Set or update an additional authority on canonical metadata accounts.' + ) + .addArgument(seedArgument) + .addArgument(programArgument) + .addOption(newAuthorityOption) + .action(doSetAuthority); +} + +type Options = NewAuthorityOption; +async function doSetAuthority( + seed: Seed, + program: Address, + _: Options, + cmd: CustomCommand +) { + const options = cmd.optsWithGlobals() as GlobalOptions & Options; + const client = await getClient(options); + const { metadata, programData } = await getPdaDetails({ + ...client, + program, + seed, + }); + + logCommand(`Setting additional authority from metadata account...`, { + 'new authority': options.newAuthority, + metadata, + program, + seed, + }); + + await client.planAndExecute( + sequentialInstructionPlan([ + getSetAuthorityInstruction({ + account: metadata, + authority: client.authority, + newAuthority: options.newAuthority, + program, + programData, + }), + ]) + ); +} diff --git a/clients/js/src/cli/commands/set-buffer-authority.ts b/clients/js/src/cli/commands/set-buffer-authority.ts new file mode 100644 index 0000000..ad136a6 --- /dev/null +++ b/clients/js/src/cli/commands/set-buffer-authority.ts @@ -0,0 +1,45 @@ +import { Address } from '@solana/kit'; +import { getSetAuthorityInstruction } from '../../generated'; +import { sequentialInstructionPlan } from '../../instructionPlans'; +import { bufferArgument } from '../arguments'; +import { logCommand } from '../logs'; +import { + GlobalOptions, + NewAuthorityOption, + newAuthorityOption, +} from '../options'; +import { CustomCommand, getClient } from '../utils'; + +export function setSetBufferAuthorityCommand(program: CustomCommand): void { + program + .command('set-buffer-authority') + .description('Update the authority of an existing buffer account.') + .addArgument(bufferArgument) + .addOption(newAuthorityOption) + .action(doSetBufferAuthority); +} + +type Options = NewAuthorityOption; +export async function doSetBufferAuthority( + buffer: Address, + _: Options, + cmd: CustomCommand +) { + const options = cmd.optsWithGlobals() as GlobalOptions & Options; + const client = await getClient(options); + + logCommand(`Updating buffer authority...`, { + buffer, + 'new authority': options.newAuthority, + }); + + await client.planAndExecute( + sequentialInstructionPlan([ + getSetAuthorityInstruction({ + account: buffer, + authority: client.authority, + newAuthority: options.newAuthority, + }), + ]) + ); +} diff --git a/clients/js/src/cli/commands/set-immutable.ts b/clients/js/src/cli/commands/set-immutable.ts new file mode 100644 index 0000000..7dc3585 --- /dev/null +++ b/clients/js/src/cli/commands/set-immutable.ts @@ -0,0 +1,58 @@ +import { Address } from '@solana/kit'; +import { getSetImmutableInstruction, Seed } from '../../generated'; +import { sequentialInstructionPlan } from '../../instructionPlans'; +import { programArgument, seedArgument } from '../arguments'; +import { logCommand } from '../logs'; +import { + GlobalOptions, + NonCanonicalWriteOption, + nonCanonicalWriteOption, +} from '../options'; +import { CustomCommand, getClient, getPdaDetailsForWriting } from '../utils'; + +export function setSetImmutableCommand(program: CustomCommand): void { + program + .command('set-immutable') + .description( + 'Make the metadata account immutable, preventing any further updates.' + ) + .addArgument(seedArgument) + .addArgument(programArgument) + .addOption(nonCanonicalWriteOption) + .action(doSetImmutable); +} + +type Options = NonCanonicalWriteOption; +async function doSetImmutable( + seed: Seed, + program: Address, + _: Options, + cmd: CustomCommand +) { + const options = cmd.optsWithGlobals() as GlobalOptions & Options; + const client = await getClient(options); + const { metadata, programData, isCanonical } = await getPdaDetailsForWriting( + client, + options, + program, + seed + ); + + logCommand(`Making metadata account immutable...`, { + metadata, + program, + seed, + authority: isCanonical ? undefined : client.authority.address, + }); + + await client.planAndExecute( + sequentialInstructionPlan([ + getSetImmutableInstruction({ + metadata, + authority: client.authority, + program, + programData, + }), + ]) + ); +} diff --git a/clients/js/src/cli/commands/update-buffer.ts b/clients/js/src/cli/commands/update-buffer.ts new file mode 100644 index 0000000..afbef64 --- /dev/null +++ b/clients/js/src/cli/commands/update-buffer.ts @@ -0,0 +1,61 @@ +import { Address, lamports } from '@solana/kit'; +import { fetchMaybeBuffer } from '../../generated'; +import { getUpdateBufferInstructionPlan } from '../../updateBuffer'; +import { bufferArgument, fileArgument } from '../arguments'; +import { logCommand, logErrorAndExit } from '../logs'; +import { GlobalOptions, setWriteOptions, WriteOptions } from '../options'; +import { CustomCommand, getClient, getWriteInput } from '../utils'; + +export function setUpdateBufferCommand(program: CustomCommand): void { + program + .command('update-buffer') + .description('Update an existing buffer account.') + .addArgument(bufferArgument) + .addArgument(fileArgument) + .tap(setWriteOptions) + .action(doUpdateBuffer); +} + +type Options = WriteOptions; +export async function doUpdateBuffer( + buffer: Address, + file: string | undefined, + _: Options, + cmd: CustomCommand +) { + const options = cmd.optsWithGlobals() as GlobalOptions & Options; + const client = await getClient(options); + + logCommand(`Updating buffer...`, { buffer }); + const [writeInput, bufferAccount] = await Promise.all([ + getWriteInput(client, file, options), + fetchMaybeBuffer(client.rpc, buffer), + ]); + + if (!bufferAccount.exists) { + logErrorAndExit(`Buffer account not found: "${buffer}"`); + } + + const currentData = bufferAccount.data.data; + const newData = writeInput.buffer?.data.data ?? writeInput.data; + const sizeDifference = newData.length - currentData.length; + const extraRent = + sizeDifference > 0 + ? await client.rpc + .getMinimumBalanceForRentExemption(BigInt(sizeDifference)) + .send() + : lamports(0n); + + await client.planAndExecute( + getUpdateBufferInstructionPlan({ + buffer, + authority: client.authority, + payer: client.payer, + extraRent, + sizeDifference, + sourceBuffer: writeInput.buffer, + closeSourceBuffer: writeInput.closeBuffer, + data: newData, + }) + ); +} diff --git a/clients/js/src/cli/commands/update.ts b/clients/js/src/cli/commands/update.ts new file mode 100644 index 0000000..6625f72 --- /dev/null +++ b/clients/js/src/cli/commands/update.ts @@ -0,0 +1,80 @@ +import { Address } from '@solana/kit'; +import picocolors from 'picocolors'; +import { fetchMaybeMetadata, Seed } from '../../generated'; +import { getUpdateMetadataInstructionPlan } from '../../updateMetadata'; +import { fileArgument, programArgument, seedArgument } from '../arguments'; +import { logCommand, logErrorAndExit } from '../logs'; +import { + GlobalOptions, + nonCanonicalWriteOption, + NonCanonicalWriteOption, + setWriteOptions, + WriteOptions, +} from '../options'; +import { + CustomCommand, + getClient, + getPdaDetailsForWriting, + getWriteInput, +} from '../utils'; + +export function setUpdateCommand(program: CustomCommand): void { + program + .command('update') + .description('Update a metadata account for a given program.') + .addArgument(seedArgument) + .addArgument(programArgument) + .addArgument(fileArgument) + .tap(setWriteOptions) + .addOption(nonCanonicalWriteOption) + .action(doWrite); +} + +type Options = WriteOptions & NonCanonicalWriteOption; +export async function doWrite( + seed: Seed, + program: Address, + file: string | undefined, + _: Options, + cmd: CustomCommand +) { + const options = cmd.optsWithGlobals() as GlobalOptions & Options; + const client = await getClient(options); + const { metadata, programData, isCanonical } = await getPdaDetailsForWriting( + client, + options, + program, + seed + ); + + logCommand(`Updating metadata account...`, { + metadata, + program, + seed, + authority: isCanonical ? undefined : client.authority.address, + }); + + const [metadataAccount, writeInput] = await Promise.all([ + fetchMaybeMetadata(client.rpc, metadata), + getWriteInput(client, file, options), + ]); + + if (!metadataAccount.exists) { + logErrorAndExit( + `Metadata account ${picocolors.bold(metadataAccount.address)} does not exist.` + ); + } + + await client.planAndExecute( + await getUpdateMetadataInstructionPlan({ + ...client, + ...writeInput, + payer: client.payer, + authority: client.authority, + program, + programData, + metadata: metadataAccount, + planner: client.planner, + }) + ); +} diff --git a/clients/js/src/cli/commands/write.ts b/clients/js/src/cli/commands/write.ts new file mode 100644 index 0000000..9453485 --- /dev/null +++ b/clients/js/src/cli/commands/write.ts @@ -0,0 +1,74 @@ +import { Address } from '@solana/kit'; +import { fetchMaybeMetadata, Seed } from '../../generated'; +import { getWriteMetadataInstructionPlan } from '../../writeMetadata'; +import { fileArgument, programArgument, seedArgument } from '../arguments'; +import { logCommand } from '../logs'; +import { + GlobalOptions, + nonCanonicalWriteOption, + NonCanonicalWriteOption, + setWriteOptions, + WriteOptions, +} from '../options'; +import { + CustomCommand, + getClient, + getPdaDetailsForWriting, + getWriteInput, +} from '../utils'; + +export function setWriteCommand(program: CustomCommand): void { + program + .command('write') + .description('Create or update a metadata account for a given program.') + .addArgument(seedArgument) + .addArgument(programArgument) + .addArgument(fileArgument) + .tap(setWriteOptions) + .addOption(nonCanonicalWriteOption) + .action(doWrite); +} + +type Options = WriteOptions & NonCanonicalWriteOption; +export async function doWrite( + seed: Seed, + program: Address, + file: string | undefined, + _: Options, + cmd: CustomCommand +) { + const options = cmd.optsWithGlobals() as GlobalOptions & Options; + const client = await getClient(options); + const { metadata, programData, isCanonical } = await getPdaDetailsForWriting( + client, + options, + program, + seed + ); + + logCommand(`Writing metadata account...`, { + metadata, + program, + seed, + authority: isCanonical ? undefined : client.authority.address, + }); + + const [metadataAccount, writeInput] = await Promise.all([ + fetchMaybeMetadata(client.rpc, metadata), + getWriteInput(client, file, options), + ]); + + await client.planAndExecute( + await getWriteMetadataInstructionPlan({ + ...client, + ...writeInput, + payer: client.payer, + authority: client.authority, + program, + programData, + seed, + metadata: metadataAccount, + planner: client.planner, + }) + ); +} diff --git a/clients/js/src/cli/index.ts b/clients/js/src/cli/index.ts new file mode 100644 index 0000000..2851943 --- /dev/null +++ b/clients/js/src/cli/index.ts @@ -0,0 +1 @@ +export * from './program'; diff --git a/clients/js/src/cli/logs.ts b/clients/js/src/cli/logs.ts new file mode 100644 index 0000000..75e7f75 --- /dev/null +++ b/clients/js/src/cli/logs.ts @@ -0,0 +1,122 @@ +import { Address } from '@solana/kit'; +import { Console } from 'node:console'; +import { Transform } from 'node:stream'; + +import picocolors from 'picocolors'; +import { Encoding } from '../generated'; + +export function logCommand( + message: string, + data: Record = {} +): void { + console.log(''); + console.log(picocolors.bold(picocolors.blue(message))); + const entries = Object.entries(data).filter( + ([_, value]) => value !== undefined + ); + if (entries.length > 0) { + console.log( + entries + .map(([key, value], i) => { + const prefix = i === entries.length - 1 ? '└─' : '├─'; + return ` ${picocolors.blue(prefix + ' ' + key)}: ${value}`; + }) + .join('\n') + ); + } + console.log(''); +} + +export function logExports( + transactionLength: number, + options: { export: Address | boolean; exportEncoding: Encoding } +): void { + const transactionPluralized = + transactionLength === 1 ? 'transaction' : 'transactions'; + const forAuthority = + typeof options.export === 'string' + ? ` for ${picocolors.bold(options.export)}` + : ''; + const encodingName = { + [Encoding.None]: 'hexadecimal', + [Encoding.Utf8]: 'UTF-8', + [Encoding.Base58]: 'Base58', + [Encoding.Base64]: 'Base64', + }[options.exportEncoding]; + const message = `Exporting ${transactionLength} ${transactionPluralized}${forAuthority} in ${encodingName}:\n`; + console.log(picocolors.yellow(message)); +} + +export function logSuccess(message: string): void { + console.warn(picocolors.green(`[Success] `) + message); +} + +export function logWarning(message: string): void { + console.warn(picocolors.yellow(`[Warning] `) + message); +} + +export function logError(message: string): void { + console.error(picocolors.red(`[Error] `) + message); +} + +export function logErrorAndExit(message: string): never { + logError(message); + process.exit(1); +} + +/** @see https://stackoverflow.com/a/69874540/11440277 */ +export function logTable(tabularData: unknown) { + const ts = new Transform({ + transform(chunk, _, cb) { + cb(null, chunk); + }, + }); + const logger = new Console({ stdout: ts }); + logger.table(tabularData); + const table: string = (ts.read() || '').toString(); + let result = ''; + for (const row of table.split(/[\r\n]+/)) { + let r = row.replace(/[^┬]*┬/, '┌'); + r = r.replace(/^├─*┼/, '├'); + r = r.replace(/│[^│]*/, ''); + r = r.replace(/^└─*┴/, '└'); + r = r.replace(/'/g, ' '); + result += `${r}\n`; + } + console.log(result); +} + +/** + * Format bytes as human-readable text. + * @see https://stackoverflow.com/a/14919494/11440277 + * + * @param bytes Number of bytes. + * @param si True to use metric (SI) units, aka powers of 1000. False to use + * binary (IEC), aka powers of 1024. + * @param dp Number of decimal places to display. + * + * @return Formatted string. + */ +export function humanFileSize(bytes: number, si = false, dp = 1) { + const thresh = si ? 1000 : 1024; + + if (Math.abs(bytes) < thresh) { + return bytes + ' B'; + } + + const units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + let u = -1; + const r = 10 ** dp; + + do { + bytes /= thresh; + ++u; + } while ( + Math.round(Math.abs(bytes) * r) / r >= thresh && + u < units.length - 1 + ); + + return bytes.toFixed(dp) + ' ' + units[u]; +} diff --git a/clients/js/src/cli/options.ts b/clients/js/src/cli/options.ts new file mode 100644 index 0000000..2749027 --- /dev/null +++ b/clients/js/src/cli/options.ts @@ -0,0 +1,222 @@ +import { type Address, type MicroLamports } from '@solana/kit'; +import { Option } from 'commander'; +import { Compression, Encoding, Format } from '../generated'; +import { logErrorAndExit } from './logs'; +import { + addressOrBooleanParser, + addressParser, + encodingParser, +} from './parsers'; +import { CustomCommand } from './utils'; + +export type GlobalOptions = KeypairOption & + PayerOption & + PriorityFeesOption & + RpcOption & + ExportOption & + ExportEncodingOption; + +export function setGlobalOptions(command: CustomCommand) { + command + .addOption(keypairOption) + .addOption(payerOption) + .addOption(priorityFeesOption) + .addOption(rpcOption) + .addOption(exportOption) + .addOption(exportEncodingOption); +} + +export type KeypairOption = { keypair?: string }; +export const keypairOption = new Option( + '-k, --keypair ', + 'Path to keypair file.' +).default(undefined, 'solana config'); + +export type PayerOption = { payer?: string }; +export const payerOption = new Option( + '-p, --payer ', + 'Path to keypair file of transaction fee and storage payer.' +).default(undefined, 'keypair option'); + +export type PriorityFeesOption = { priorityFees?: MicroLamports }; +export const priorityFeesOption = new Option( + '--priority-fees ', + 'Priority fees in micro-lamports per compute unit for sending transactions.' +) + .default('100000') + .argParser((value: string | undefined) => + value !== undefined ? (BigInt(value) as MicroLamports) : undefined + ); + +export type RpcOption = { rpc?: string }; +export const rpcOption = new Option('--rpc ', 'RPC URL.').default( + undefined, + 'solana config or localhost' +); + +export type ExportOption = { export: Address | boolean }; +export const exportOption = new Option( + '--export [address]', + 'When provided, export transactions instead of running them. An optional address can be provided to override the local keypair as the authority.' +) + .default(false) + .argParser(addressOrBooleanParser('export')); + +export type ExportEncodingOption = { exportEncoding: Encoding }; +export const exportEncodingOption = new Option( + '--export-encoding ', + 'Describes how to encode exported transactions.' +) + .choices(['none', 'utf8', 'base58', 'base64']) + .default(Encoding.Base64, 'base64') + .argParser(encodingParser); + +export type WriteOptions = TextOption & + UrlOption & + AccountOption & + AccountOffsetOption & + AccountLengthOption & + BufferOption & + CloseBufferOption & + CompressionOption & + EncodingOption & + FormatOption; + +export function setWriteOptions(command: CustomCommand) { + command + // Data sources. + .addOption(textOption) + .addOption(urlOption) + .addOption(accountOption) + .addOption(accountOffsetOption) + .addOption(accountLengthOption) + .addOption(bufferOption) + .addOption(closeBufferOption) + // Enums. + .addOption(compressionOption) + .addOption(encodingOption) + .addOption(formatOption); +} + +export type TextOption = { text?: string }; +export const textOption = new Option( + '--text ', + 'Direct content to upload (creates a "direct" data source).' +); + +export type UrlOption = { url?: string }; +export const urlOption = new Option( + '--url ', + 'The url to upload (creates a "url" data source).' +); + +export type AccountOption = { account?: string }; +export const accountOption = new Option( + '--account
', + 'The account address to upload (creates an "external" data source). See also: "--account-offset" and "--account-length".' +); + +export type AccountOffsetOption = { accountOffset?: string }; +export const accountOffsetOption = new Option( + '--account-offset ', + 'The offset in which the data start on the provided account. Requires "--account" to be set.' +).default(undefined, '0'); + +export type AccountLengthOption = { accountLength?: string }; +export const accountLengthOption = new Option( + '--account-length ', + 'The length of the data on the provided account. Requires "--account" to be set.' +).default(undefined, 'the rest of the data'); + +export type BufferOption = { buffer?: Address }; +export const bufferOption = new Option( + '--buffer
', + 'The address of the buffer to use as source (creates a "direct" data source).' +); + +export type CloseBufferOption = { closeBuffer: Address | boolean }; +export const closeBufferOption = new Option( + '--close-buffer [address]', + 'Whether to close provided `--buffer` account after using its data.' +) + .default(false) + .argParser(addressOrBooleanParser('--close-buffer recipient')); + +export type CompressionOption = { compression: Compression }; +export const compressionOption = new Option( + '--compression ', + 'Describes how to compress the data.' +) + .choices(['none', 'gzip', 'zlib']) + .default(Compression.Zlib, 'zlib') + .argParser((value: string): Compression => { + switch (value) { + case 'none': + return Compression.None; + case 'gzip': + return Compression.Gzip; + case 'zlib': + return Compression.Zlib; + default: + logErrorAndExit(`Invalid compression option: ${value}`); + } + }); + +export type EncodingOption = { encoding: Encoding }; +export const encodingOption = new Option( + '--encoding ', + 'Describes how to encode the data.' +) + .choices(['none', 'utf8', 'base58', 'base64']) + .default(Encoding.Utf8, 'utf8') + .argParser(encodingParser); + +export type FormatOption = { format?: Format }; +export const formatOption = new Option( + '--format ', + 'The format of the provided data.' +) + .choices(['none', 'json', 'yaml', 'toml']) + .default(undefined, 'the file extension or "none"') + .argParser((value: string): Format => { + switch (value) { + case 'none': + return Format.None; + case 'json': + return Format.Json; + case 'yaml': + return Format.Yaml; + case 'toml': + return Format.Toml; + default: + logErrorAndExit(`Invalid format option: ${value}`); + } + }); + +export type NonCanonicalWriteOption = { nonCanonical: boolean }; +export const nonCanonicalWriteOption = new Option( + '--non-canonical', + 'When provided, a non-canonical metadata account will be closed using the active keypair as the authority.' +).default(false); + +export type NonCanonicalReadOption = { nonCanonical: Address | boolean }; +export const nonCanonicalReadOption = new Option( + '--non-canonical [address]', + 'When provided, a non-canonical metadata account will be downloaded using the provided address or the active keypair as the authority.' +) + .default(false) + .argParser(addressOrBooleanParser('non-canonical')); + +export type OutputOption = { output?: string }; +export const outputOption = new Option( + '-o, --output ', + 'Path to save the retrieved data.' +); + +export type NewAuthorityOption = { newAuthority: Address }; +export const newAuthorityOption = new Option( + '--new-authority ', + 'The new authority to set' +) + .makeOptionMandatory() + .argParser(addressParser('new authority')); diff --git a/clients/js/src/cli/parsers.ts b/clients/js/src/cli/parsers.ts new file mode 100644 index 0000000..89e94a8 --- /dev/null +++ b/clients/js/src/cli/parsers.ts @@ -0,0 +1,35 @@ +import { Address, address } from '@solana/kit'; +import { logErrorAndExit } from './logs'; +import { Encoding } from '../generated'; + +export const addressParser = + (identifier: string) => + (value: string): Address => { + try { + return address(value); + } catch { + logErrorAndExit(`Invalid ${identifier} address: "${value}"`); + } + }; + +export const addressOrBooleanParser = + (identifier: string) => + (value: string | undefined): Address | boolean => { + if (value === undefined) return true; + return addressParser(identifier)(value); + }; + +export const encodingParser = (value: string): Encoding => { + switch (value) { + case 'none': + return Encoding.None; + case 'utf8': + return Encoding.Utf8; + case 'base58': + return Encoding.Base58; + case 'base64': + return Encoding.Base64; + default: + logErrorAndExit(`Invalid encoding option: ${value}`); + } +}; diff --git a/clients/js/src/cli/program.ts b/clients/js/src/cli/program.ts new file mode 100644 index 0000000..88b57df --- /dev/null +++ b/clients/js/src/cli/program.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node + +import { setCommands } from './commands'; +import { setGlobalOptions } from './options'; +import { CustomCommand } from './utils'; + +// Define the CLI program. +const program = new CustomCommand(); +program + .name('program-metadata') + .description('CLI to manage Solana program metadata and IDLs') + .version(__VERSION__) + .configureHelp({ showGlobalOptions: true }) + .tap(setGlobalOptions) + .tap(setCommands) + .parse(); diff --git a/clients/js/src/cli/utils.ts b/clients/js/src/cli/utils.ts new file mode 100644 index 0000000..b5517bc --- /dev/null +++ b/clients/js/src/cli/utils.ts @@ -0,0 +1,384 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + Account, + Address, + address, + Commitment, + CompilableTransactionMessage, + compileTransaction, + createKeyPairSignerFromBytes, + createNoopSigner, + createSolanaRpc, + createSolanaRpcSubscriptions, + getTransactionEncoder, + MessageSigner, + pipe, + Rpc, + RpcSubscriptions, + setTransactionMessageLifetimeUsingBlockhash, + SolanaRpcApi, + SolanaRpcSubscriptionsApi, + TransactionSigner, +} from '@solana/kit'; +import { Command } from 'commander'; +import picocolors from 'picocolors'; +import { parse as parseYaml } from 'yaml'; +import { Buffer, DataSource, fetchBuffer, Format, Seed } from '../generated'; +import { + createDefaultTransactionPlanExecutor, + createDefaultTransactionPlanner, + getAllSingleTransactionPlans, + getSetComputeUnitLimitInstructionIndex, + InstructionPlan, + TransactionPlan, + TransactionPlanExecutor, + TransactionPlanner, +} from '../instructionPlans'; +import { getPdaDetails, PdaDetails } from '../internals'; +import { + decodeData, + packDirectData, + PackedData, + packExternalData, + packUrlData, +} from '../packData'; +import { logErrorAndExit, logExports, logSuccess, logWarning } from './logs'; +import { + ExportOption, + GlobalOptions, + KeypairOption, + NonCanonicalWriteOption, + PayerOption, + RpcOption, + WriteOptions, +} from './options'; + +const LOCALHOST_URL = 'http://127.0.0.1:8899'; +const DATA_SOURCE_OPTIONS = + '`[file]`, `--buffer
`, `--text `, `--url ` or `--account
`'; + +export class CustomCommand extends Command { + createCommand(name: string) { + return new CustomCommand(name); + } + + tap(fn: (command: CustomCommand) => void) { + fn(this); + return this; + } +} + +export type Client = ReadonlyClient & { + authority: TransactionSigner & MessageSigner; + executor: TransactionPlanExecutor; + payer: TransactionSigner & MessageSigner; + planAndExecute: (instructionPlan: InstructionPlan) => Promise; + planner: TransactionPlanner; +}; + +export async function getClient(options: GlobalOptions): Promise { + const readonlyClient = getReadonlyClient(options); + const [authority, payer] = await getKeyPairSigners( + options, + readonlyClient.configs + ); + const planner = createDefaultTransactionPlanner({ + feePayer: payer, + computeUnitPrice: options.priorityFees, + }); + const executor = createDefaultTransactionPlanExecutor({ + rpc: readonlyClient.rpc, + rpcSubscriptions: readonlyClient.rpcSubscriptions, + parallelChunkSize: 5, + }); + const planAndExecute = async (instructionPlan: InstructionPlan) => { + const transactionPlan = await planner(instructionPlan); + if (options.export) { + await exportTransactionPlan(transactionPlan, readonlyClient, options); + } else { + await executeTransactionPlan(transactionPlan, executor); + } + }; + return { + ...readonlyClient, + authority, + executor, + payer, + planAndExecute, + planner, + }; +} + +async function exportTransactionPlan( + transactionPlan: TransactionPlan, + client: Pick, + options: GlobalOptions +) { + const singleTransactions = getAllSingleTransactionPlans(transactionPlan); + const transactionEncoder = getTransactionEncoder(); + + logExports(singleTransactions.length, options); + + const { value: latestBlockhash } = await client.rpc + .getLatestBlockhash() + .send(); + + for (let i = 0; i < singleTransactions.length; i++) { + const transaction = pipe( + singleTransactions[i].message, + (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), + removeComputeUnitLimitInstruction, + compileTransaction + ); + const encodedTransaction = decodeData( + transactionEncoder.encode(transaction), + options.exportEncoding + ); + const prefix = picocolors.yellow(`[Transaction #${i + 1}]`); + console.log(`${prefix}\n${encodedTransaction}\n`); + } +} + +function removeComputeUnitLimitInstruction< + TTransactionMessage extends CompilableTransactionMessage, +>(message: TTransactionMessage): TTransactionMessage { + const index = getSetComputeUnitLimitInstructionIndex(message); + if (index === -1) return message; + return { + ...message, + instructions: message.instructions.filter((_, i) => i !== index), + }; +} + +async function executeTransactionPlan( + transactionPlan: TransactionPlan, + executor: TransactionPlanExecutor +) { + // TODO: progress + error handling + await executor(transactionPlan); + logSuccess('Operation executed successfully'); +} + +export type ReadonlyClient = { + configs: SolanaConfigs; + rpc: Rpc; + rpcSubscriptions: RpcSubscriptions; +}; + +export function getReadonlyClient(options: RpcOption): ReadonlyClient { + const configs = getSolanaConfigs(); + const rpcUrl = getRpcUrl(options, configs); + const rpcSubscriptionsUrl = getRpcSubscriptionsUrl(rpcUrl, configs); + return { + configs, + rpc: createSolanaRpc(rpcUrl), + rpcSubscriptions: createSolanaRpcSubscriptions(rpcSubscriptionsUrl), + }; +} + +function getRpcUrl(options: RpcOption, configs: SolanaConfigs): string { + if (options.rpc) return options.rpc; + if (configs.json_rpc_url) return configs.json_rpc_url; + return LOCALHOST_URL; +} + +function getRpcSubscriptionsUrl( + rpcUrl: string, + configs: SolanaConfigs +): string { + if (configs.websocket_url) return configs.websocket_url; + return rpcUrl.replace(/^http/, 'ws').replace(/:8899$/, ':8900'); +} + +type SolanaConfigs = { + json_rpc_url?: string; + websocket_url?: string; + keypair_path?: string; + commitment?: Commitment; +}; + +function getSolanaConfigs(): SolanaConfigs { + const path = getSolanaConfigPath(); + if (!fs.existsSync(path)) { + logWarning('Solana config file not found'); + return {}; + } + return parseYaml(fs.readFileSync(getSolanaConfigPath(), 'utf8')); +} + +function getSolanaConfigPath(): string { + return path.join(os.homedir(), '.config', 'solana', 'cli', 'config.yml'); +} + +export async function getKeyPairSigners( + options: KeypairOption & PayerOption & ExportOption, + configs: SolanaConfigs +): Promise< + [TransactionSigner & MessageSigner, TransactionSigner & MessageSigner] +> { + if (typeof options.export === 'string') { + const exportSigner = createNoopSigner(options.export); + return [exportSigner, exportSigner]; + } + const keypairPath = getKeyPairPath(options, configs); + const keypairPromise = getKeyPairSignerFromPath(keypairPath); + const payerPromise = options.payer + ? getKeyPairSignerFromPath(options.payer) + : keypairPromise; + return await Promise.all([keypairPromise, payerPromise]); +} + +function getKeyPairPath( + options: KeypairOption, + configs: SolanaConfigs +): string { + if (options.keypair) return options.keypair; + if (configs.keypair_path) return configs.keypair_path; + return path.join(os.homedir(), '.config', 'solana', 'id.json'); +} + +async function getKeyPairSignerFromPath(keypairPath: string) { + if (!fs.existsSync(keypairPath)) { + logErrorAndExit(`Keypair file not found at: ${keypairPath}`); + } + const keypairString = fs.readFileSync(keypairPath, 'utf-8'); + const keypairData = new Uint8Array(JSON.parse(keypairString)); + return await createKeyPairSignerFromBytes(keypairData); +} + +export function getFormatFromFile(file: string | undefined): Format { + if (!file) return Format.None; + const extension = path.extname(file); + switch (extension) { + case '.json': + return Format.Json; + case '.yaml': + case '.yml': + return Format.Yaml; + case '.toml': + return Format.Toml; + default: + return Format.None; + } +} + +export async function getPdaDetailsForWriting( + client: Client, + options: NonCanonicalWriteOption, + program: Address, + seed: Seed +): Promise { + const details = await getPdaDetails({ ...client, program, seed }); + assertValidIsCanonical(details.isCanonical, options); + const isCanonical = !options.nonCanonical; + return { + programData: isCanonical ? details.programData : undefined, + metadata: details.metadata, + isCanonical, + }; +} + +export async function getWriteInput( + client: Client, + file: string | undefined, + options: WriteOptions +): Promise< + PackedData & { + buffer?: Account; + format: Format; + closeBuffer?: Address | boolean; + } +> { + const buffer = options.buffer + ? await fetchBuffer(client.rpc, options.buffer) + : undefined; + return { + ...getPackedData(file, options), + format: options.format ?? getFormatFromFile(file), + closeBuffer: options.buffer ? options.closeBuffer : true, + buffer, + }; +} + +export function getPackedData( + file: string | undefined, + options: WriteOptions +): PackedData { + const { compression, encoding } = options; + let packData: PackedData | null = null; + const assertSingleUse = () => { + if (packData) { + logErrorAndExit( + `Multiple data sources provided. Use only one of: ${DATA_SOURCE_OPTIONS} to provide data.` + ); + } + }; + + if (file) { + if (!fs.existsSync(file)) { + logErrorAndExit(`File not found: ${file}`); + } + const fileContent = fs.readFileSync(file, 'utf-8'); + packData = packDirectData({ content: fileContent, compression, encoding }); + } + if (options.buffer) { + assertSingleUse(); + packData = { + data: new Uint8Array(0), + compression, + encoding, + dataSource: DataSource.Direct, + }; + } + if (options.text) { + assertSingleUse(); + packData = packDirectData({ content: options.text, compression, encoding }); + } + if (options.url) { + assertSingleUse(); + packData = packUrlData({ url: options.url, compression, encoding }); + } + if (options.account) { + assertSingleUse(); + packData = packExternalData({ + address: address(options.account), + offset: options.accountOffset + ? parseInt(options.accountOffset) + : undefined, + length: options.accountLength + ? parseInt(options.accountLength) + : undefined, + compression, + encoding, + }); + } + + if (!packData) { + logErrorAndExit( + `No data provided. Use ${DATA_SOURCE_OPTIONS} to provide data.` + ); + } + + return packData; +} + +export function writeFile(filepath: string, content: string): void { + fs.mkdirSync(path.dirname(filepath), { recursive: true }); + fs.writeFileSync(filepath, content); +} + +export function assertValidIsCanonical( + isCanonical: boolean, + options: NonCanonicalWriteOption +): void { + const wantsCanonical = !options.nonCanonical; + if (wantsCanonical && !isCanonical) { + logErrorAndExit( + 'You must be the program authority or an authorized authority to manage canonical metadata accounts. ' + + 'Use the `--non-canonical` option to manage metadata accounts as a third party.' + ); + } +} diff --git a/clients/js/src/createBuffer.ts b/clients/js/src/createBuffer.ts new file mode 100644 index 0000000..46b5718 --- /dev/null +++ b/clients/js/src/createBuffer.ts @@ -0,0 +1,89 @@ +import { getCreateAccountInstruction } from '@solana-program/system'; +import { + Account, + Address, + Lamports, + ReadonlyUint8Array, + TransactionSigner, +} from '@solana/kit'; +import { + Buffer, + getAllocateInstruction, + getCloseInstruction, + getSetAuthorityInstruction, + getWriteInstruction, + PROGRAM_METADATA_PROGRAM_ADDRESS, +} from './generated'; +import { + parallelInstructionPlan, + sequentialInstructionPlan, +} from './instructionPlans'; +import { getAccountSize, getWriteInstructionPlan } from './utils'; + +export function getCreateBufferInstructionPlan(input: { + newBuffer: TransactionSigner; + authority: TransactionSigner; + payer: TransactionSigner; + sourceBuffer?: Account; + closeSourceBuffer?: Address | boolean; + data?: ReadonlyUint8Array; + rent: Lamports; +}) { + if (!input.data && !input.sourceBuffer) { + throw new Error( + 'Either `data` or `sourceBuffer` must be provided to create a buffer.' + ); + } + + const data = (input.sourceBuffer?.data.data ?? + input.data) as ReadonlyUint8Array; + + return sequentialInstructionPlan([ + getCreateAccountInstruction({ + payer: input.payer, + newAccount: input.newBuffer, + lamports: input.rent, + space: getAccountSize(data.length), + programAddress: PROGRAM_METADATA_PROGRAM_ADDRESS, + }), + getAllocateInstruction({ + buffer: input.newBuffer.address, + authority: input.newBuffer, + }), + getSetAuthorityInstruction({ + account: input.newBuffer.address, + authority: input.newBuffer, + newAuthority: input.authority.address, + }), + ...(input.sourceBuffer + ? [ + getWriteInstruction({ + buffer: input.newBuffer.address, + authority: input.authority, + sourceBuffer: input.sourceBuffer.address, + offset: 0, + }), + ] + : [ + parallelInstructionPlan([ + getWriteInstructionPlan({ + buffer: input.newBuffer.address, + authority: input.authority, + data, + }), + ]), + ]), + ...(input.sourceBuffer && input.closeSourceBuffer + ? [ + getCloseInstruction({ + account: input.sourceBuffer.address, + authority: input.authority, + destination: + typeof input.closeSourceBuffer === 'string' + ? input.closeSourceBuffer + : input.payer.address, + }), + ] + : []), + ]); +} diff --git a/clients/js/src/createMetadata.ts b/clients/js/src/createMetadata.ts index 86a108e..489a110 100644 --- a/clients/js/src/createMetadata.ts +++ b/clients/js/src/createMetadata.ts @@ -1,5 +1,7 @@ import { getTransferSolInstruction } from '@solana-program/system'; import { + Account, + Address, GetMinimumBalanceForRentExemptionApi, Lamports, ReadonlyUint8Array, @@ -7,24 +9,35 @@ import { TransactionSigner, } from '@solana/kit'; import { + Buffer, + fetchBuffer, getAllocateInstruction, + getCloseInstruction, getInitializeInstruction, + getWriteInstruction, InitializeInput, PROGRAM_METADATA_PROGRAM_ADDRESS, } from './generated'; import { createDefaultTransactionPlanExecutor, - createDefaultTransactionPlanner, + InstructionPlan, + isValidInstructionPlan, parallelInstructionPlan, sequentialInstructionPlan, + TransactionPlanner, } from './instructionPlans'; import { - getExtendInstructionPlan, + getDefaultTransactionPlannerAndExecutor, getPdaDetails, - getWriteInstructionPlan, REALLOC_LIMIT, } from './internals'; -import { getAccountSize, MetadataInput, MetadataResponse } from './utils'; +import { + getAccountSize, + getExtendInstructionPlan, + getWriteInstructionPlan, + MetadataInput, + MetadataResponse, +} from './utils'; export async function createMetadata( input: MetadataInput & { @@ -35,39 +48,65 @@ export async function createMetadata( >[0]['rpcSubscriptions']; } ): Promise { - const planner = createDefaultTransactionPlanner({ - feePayer: input.payer, - computeUnitPrice: input.priorityFees, - }); - const executor = createDefaultTransactionPlanExecutor({ - rpc: input.rpc, - rpcSubscriptions: input.rpcSubscriptions, - parallelChunkSize: 5, - }); - - const [{ programData, isCanonical, metadata }, rent] = await Promise.all([ + const { planner, executor } = getDefaultTransactionPlannerAndExecutor(input); + const [{ programData, isCanonical, metadata }, buffer] = await Promise.all([ getPdaDetails(input), - input.rpc - .getMinimumBalanceForRentExemption(getAccountSize(input.data.length)) - .send(), + input.buffer + ? fetchBuffer(input.rpc, input.buffer) + : Promise.resolve(undefined), ]); - const extendedInput = { + const instructionPlan = await getCreateMetadataInstructionPlan({ ...input, - programData: isCanonical ? programData : undefined, + buffer, metadata, - rent, - }; - - const transactionPlan = await planner( - getCreateMetadataInstructionPlanUsingInstructionData(extendedInput) - ).catch(() => - planner(getCreateMetadataInstructionPlanUsingBuffer(extendedInput)) - ); + planner, + programData: isCanonical ? programData : undefined, + }); + const transactionPlan = await planner(instructionPlan); const result = await executor(transactionPlan); return { metadata, result }; } +export async function getCreateMetadataInstructionPlan( + input: Omit & { + buffer?: Account; + data?: ReadonlyUint8Array; + payer: TransactionSigner; + planner: TransactionPlanner; + rpc: Rpc; + closeBuffer?: Address | boolean; + } +): Promise { + if (!input.buffer && !input.data) { + throw new Error( + 'Either `buffer` or `data` must be provided to create a new metadata account.' + ); + } + + const data = (input.buffer?.data.data ?? input.data) as ReadonlyUint8Array; + const rent = await input.rpc + .getMinimumBalanceForRentExemption(getAccountSize(data.length)) + .send(); + + if (input.buffer) { + return getCreateMetadataInstructionPlanUsingExistingBuffer({ + ...input, + buffer: input.buffer.address, + dataLength: data.length, + rent, + }); + } + + const extendedInput = { ...input, rent, data }; + const plan = + getCreateMetadataInstructionPlanUsingInstructionData(extendedInput); + const validPlan = await isValidInstructionPlan(plan, input.planner); + return validPlan + ? plan + : getCreateMetadataInstructionPlanUsingNewBuffer(extendedInput); +} + export function getCreateMetadataInstructionPlanUsingInstructionData( input: InitializeInput & { payer: TransactionSigner; rent: Lamports } ) { @@ -81,7 +120,7 @@ export function getCreateMetadataInstructionPlanUsingInstructionData( ]); } -export function getCreateMetadataInstructionPlanUsingBuffer( +export function getCreateMetadataInstructionPlanUsingNewBuffer( input: Omit & { data: ReadonlyUint8Array; payer: TransactionSigner; @@ -126,3 +165,62 @@ export function getCreateMetadataInstructionPlanUsingBuffer( }), ]); } + +export function getCreateMetadataInstructionPlanUsingExistingBuffer( + input: Omit & { + buffer: Address; + dataLength: number; + payer: TransactionSigner; + rent: Lamports; + closeBuffer?: Address | boolean; + } +) { + return sequentialInstructionPlan([ + getTransferSolInstruction({ + source: input.payer, + destination: input.metadata, + amount: input.rent, + }), + getAllocateInstruction({ + buffer: input.metadata, + authority: input.authority, + program: input.program, + programData: input.programData, + seed: input.seed, + }), + ...(input.dataLength > REALLOC_LIMIT + ? [ + getExtendInstructionPlan({ + account: input.metadata, + authority: input.authority, + extraLength: input.dataLength, + program: input.program, + programData: input.programData, + }), + ] + : []), + getWriteInstruction({ + buffer: input.metadata, + authority: input.authority, + sourceBuffer: input.buffer, + offset: 0, + }), + getInitializeInstruction({ + ...input, + system: PROGRAM_METADATA_PROGRAM_ADDRESS, + data: undefined, + }), + ...(input.closeBuffer + ? [ + getCloseInstruction({ + account: input.buffer, + authority: input.authority, + destination: + typeof input.closeBuffer === 'string' + ? input.closeBuffer + : input.payer.address, + }), + ] + : []), + ]); +} diff --git a/clients/js/src/downloadMetadata.ts b/clients/js/src/fetchMetadataContent.ts similarity index 92% rename from clients/js/src/downloadMetadata.ts rename to clients/js/src/fetchMetadataContent.ts index 671c42a..fbc7e21 100644 --- a/clients/js/src/downloadMetadata.ts +++ b/clients/js/src/fetchMetadataContent.ts @@ -4,7 +4,7 @@ import { parse as parseYaml } from 'yaml'; import { fetchMetadataFromSeeds, Format, SeedArgs } from './generated'; import { unpackAndFetchData } from './packData'; -export async function downloadMetadata( +export async function fetchMetadataContent( rpc: Rpc, program: Address, seed: SeedArgs, @@ -18,7 +18,7 @@ export async function downloadMetadata( return await unpackAndFetchData({ rpc, ...account.data }); } -export async function downloadAndParseMetadata( +export async function fetchAndParseMetadataContent( rpc: Rpc, program: Address, seed: SeedArgs, diff --git a/clients/js/src/index.ts b/clients/js/src/index.ts index 5ce6031..ea72bca 100644 --- a/clients/js/src/index.ts +++ b/clients/js/src/index.ts @@ -2,8 +2,8 @@ export * from './generated'; export * from './instructionPlans'; export * from './createMetadata'; -export * from './downloadMetadata'; +export * from './fetchMetadataContent'; export * from './packData'; export * from './updateMetadata'; -export * from './uploadMetadata'; export * from './utils'; +export * from './writeMetadata'; diff --git a/clients/js/src/instructionPlans/computeBudgetHelpers.ts b/clients/js/src/instructionPlans/computeBudgetHelpers.ts index f7f8368..4627d47 100644 --- a/clients/js/src/instructionPlans/computeBudgetHelpers.ts +++ b/clients/js/src/instructionPlans/computeBudgetHelpers.ts @@ -125,7 +125,7 @@ function getSetComputeUnitLimitInstructionIndexAndUnits( return { index, units }; } -function getSetComputeUnitLimitInstructionIndex( +export function getSetComputeUnitLimitInstructionIndex( transactionMessage: BaseTransactionMessage ) { return transactionMessage.instructions.findIndex((ix) => { diff --git a/clients/js/src/instructionPlans/transactionPlanExecutorBase.ts b/clients/js/src/instructionPlans/transactionPlanExecutorBase.ts index d8068e6..5d60e40 100644 --- a/clients/js/src/instructionPlans/transactionPlanExecutorBase.ts +++ b/clients/js/src/instructionPlans/transactionPlanExecutorBase.ts @@ -48,6 +48,7 @@ export function createBaseTransactionPlanExecutor( result: TransactionPlanResult; }; error.result = result; + error.cause = findErrorFromTransactionPlanResult(result); throw error; } @@ -169,3 +170,17 @@ async function traverseSingle( }; } } + +function findErrorFromTransactionPlanResult( + result: TransactionPlanResult +): Error | undefined { + if (result.kind === 'single') { + return result.status.kind === 'failed' ? result.status.error : undefined; + } + for (const plan of result.plans) { + const error = findErrorFromTransactionPlanResult(plan); + if (error) { + return error; + } + } +} diff --git a/clients/js/src/instructionPlans/transactionPlanner.ts b/clients/js/src/instructionPlans/transactionPlanner.ts index f960d73..17e71ce 100644 --- a/clients/js/src/instructionPlans/transactionPlanner.ts +++ b/clients/js/src/instructionPlans/transactionPlanner.ts @@ -5,3 +5,15 @@ export type TransactionPlanner = ( instructionPlan: InstructionPlan, config?: { abortSignal?: AbortSignal } ) => Promise; + +export async function isValidInstructionPlan( + instructionPlan: InstructionPlan, + planner: TransactionPlanner +) { + try { + await planner(instructionPlan); + return true; + } catch { + return false; + } +} diff --git a/clients/js/src/internals.ts b/clients/js/src/internals.ts index 127c8c6..56c5436 100644 --- a/clients/js/src/internals.ts +++ b/clients/js/src/internals.ts @@ -1,22 +1,16 @@ import { Address, GetAccountInfoApi, - ReadonlyUint8Array, + MicroLamports, Rpc, TransactionSigner, } from '@solana/kit'; +import { findMetadataPda, SeedArgs } from './generated'; +import { getProgramAuthority } from './utils'; import { - findMetadataPda, - getExtendInstruction, - getWriteInstruction, - SeedArgs, -} from './generated'; -import { - getLinearIterableInstructionPlan, - getReallocIterableInstructionPlan, - IterableInstructionPlan, + createDefaultTransactionPlanExecutor, + createDefaultTransactionPlanner, } from './instructionPlans'; -import { getProgramAuthority } from './utils'; export const REALLOC_LIMIT = 10_240; @@ -49,39 +43,22 @@ export async function getPdaDetails(input: { return { metadata, isCanonical, programData }; } -export function getExtendInstructionPlan(input: { - account: Address; - authority: TransactionSigner; - extraLength: number; - program?: Address; - programData?: Address; -}): IterableInstructionPlan { - return getReallocIterableInstructionPlan({ - totalSize: input.extraLength, - getInstruction: (size) => - getExtendInstruction({ - account: input.account, - authority: input.authority, - length: size, - program: input.program, - programData: input.programData, - }), +export function getDefaultTransactionPlannerAndExecutor(input: { + payer: TransactionSigner; + priorityFees?: MicroLamports; + rpc: Parameters[0]['rpc']; + rpcSubscriptions: Parameters< + typeof createDefaultTransactionPlanExecutor + >[0]['rpcSubscriptions']; +}) { + const planner = createDefaultTransactionPlanner({ + feePayer: input.payer, + computeUnitPrice: input.priorityFees, }); -} - -export function getWriteInstructionPlan(input: { - buffer: Address; - authority: TransactionSigner; - data: ReadonlyUint8Array; -}): IterableInstructionPlan { - return getLinearIterableInstructionPlan({ - totalLength: input.data.length, - getInstruction: (offset, length) => - getWriteInstruction({ - buffer: input.buffer, - authority: input.authority, - offset, - data: input.data.slice(offset, offset + length), - }), + const executor = createDefaultTransactionPlanExecutor({ + rpc: input.rpc, + rpcSubscriptions: input.rpcSubscriptions, + parallelChunkSize: 5, }); + return { planner, executor }; } diff --git a/clients/js/src/updateBuffer.ts b/clients/js/src/updateBuffer.ts new file mode 100644 index 0000000..d70ac23 --- /dev/null +++ b/clients/js/src/updateBuffer.ts @@ -0,0 +1,100 @@ +import { getTransferSolInstruction } from '@solana-program/system'; +import { + Account, + Address, + Lamports, + ReadonlyUint8Array, + TransactionSigner, +} from '@solana/kit'; +import { + Buffer, + getCloseInstruction, + getTrimInstruction, + getWriteInstruction, +} from './generated'; +import { + parallelInstructionPlan, + sequentialInstructionPlan, +} from './instructionPlans'; +import { REALLOC_LIMIT } from './internals'; +import { getExtendInstructionPlan, getWriteInstructionPlan } from './utils'; + +export function getUpdateBufferInstructionPlan(input: { + buffer: Address; + authority: TransactionSigner; + payer: TransactionSigner; + extraRent: Lamports; + sizeDifference: number | bigint; + sourceBuffer?: Account; + closeSourceBuffer?: Address | boolean; + data?: ReadonlyUint8Array; +}) { + if (!input.data && !input.sourceBuffer) { + throw new Error( + 'Either `data` or `sourceBuffer` must be provided to update a buffer.' + ); + } + + const data = (input.sourceBuffer?.data.data ?? + input.data) as ReadonlyUint8Array; + + return sequentialInstructionPlan([ + ...(input.sizeDifference > 0 + ? [ + getTransferSolInstruction({ + source: input.payer, + destination: input.buffer, + amount: input.extraRent, + }), + ] + : []), + ...(input.sizeDifference > REALLOC_LIMIT + ? [ + getExtendInstructionPlan({ + account: input.buffer, + authority: input.authority, + extraLength: Number(input.sizeDifference), + }), + ] + : []), + ...(input.sourceBuffer + ? [ + getWriteInstruction({ + buffer: input.buffer, + authority: input.authority, + sourceBuffer: input.sourceBuffer.address, + offset: 0, + }), + ] + : [ + parallelInstructionPlan([ + getWriteInstructionPlan({ + buffer: input.buffer, + authority: input.authority, + data, + }), + ]), + ]), + ...(input.sizeDifference < 0 + ? [ + getTrimInstruction({ + account: input.buffer, + authority: input.authority, + destination: input.payer.address, + }), + ] + : []), + ...(input.sourceBuffer && input.closeSourceBuffer + ? [ + getCloseInstruction({ + account: input.sourceBuffer.address, + authority: input.authority, + destination: + typeof input.closeSourceBuffer === 'string' + ? input.closeSourceBuffer + : input.payer.address, + }), + ] + : []), + ]); +} diff --git a/clients/js/src/updateMetadata.ts b/clients/js/src/updateMetadata.ts index ac47487..a7af747 100644 --- a/clients/js/src/updateMetadata.ts +++ b/clients/js/src/updateMetadata.ts @@ -1,8 +1,7 @@ +import { getTransferSolInstruction } from '@solana-program/system'; import { - getCreateAccountInstruction, - getTransferSolInstruction, -} from '@solana-program/system'; -import { + Account, + Address, generateKeyPairSigner, GetAccountInfoApi, GetMinimumBalanceForRentExemptionApi, @@ -12,29 +11,35 @@ import { Rpc, TransactionSigner, } from '@solana/kit'; +import { getCreateBufferInstructionPlan } from './createBuffer'; import { + Buffer, + fetchBuffer, fetchMetadata, - getAllocateInstruction, getCloseInstruction, - getSetAuthorityInstruction, getSetDataInstruction, getTrimInstruction, - PROGRAM_METADATA_PROGRAM_ADDRESS, + Metadata, SetDataInput, } from './generated'; import { createDefaultTransactionPlanExecutor, - createDefaultTransactionPlanner, - parallelInstructionPlan, + InstructionPlan, + isValidInstructionPlan, sequentialInstructionPlan, + TransactionPlanner, } from './instructionPlans'; import { - getExtendInstructionPlan, + getDefaultTransactionPlannerAndExecutor, getPdaDetails, - getWriteInstructionPlan, REALLOC_LIMIT, } from './internals'; -import { getAccountSize, MetadataInput, MetadataResponse } from './utils'; +import { + getAccountSize, + getExtendInstructionPlan, + MetadataInput, + MetadataResponse, +} from './utils'; export async function updateMetadata( input: MetadataInput & { @@ -45,58 +50,91 @@ export async function updateMetadata( >[0]['rpcSubscriptions']; } ): Promise { - const planner = createDefaultTransactionPlanner({ - feePayer: input.payer, - computeUnitPrice: input.priorityFees, - }); - const executor = createDefaultTransactionPlanExecutor({ - rpc: input.rpc, - rpcSubscriptions: input.rpcSubscriptions, - parallelChunkSize: 5, + const { planner, executor } = getDefaultTransactionPlannerAndExecutor(input); + const { programData, isCanonical, metadata } = await getPdaDetails(input); + const [metadataAccount, buffer] = await Promise.all([ + fetchMetadata(input.rpc, metadata), + input.buffer + ? fetchBuffer(input.rpc, input.buffer) + : Promise.resolve(undefined), + ]); + + const instructionPlan = await getUpdateMetadataInstructionPlan({ + ...input, + buffer, + metadata: metadataAccount, + planner, + programData: isCanonical ? programData : undefined, }); - const [{ programData, isCanonical, metadata }, bufferRent] = - await Promise.all([ - getPdaDetails(input), - input.rpc - .getMinimumBalanceForRentExemption(getAccountSize(input.data.length)) - .send(), - ]); + const transactionPlan = await planner(instructionPlan); + const result = await executor(transactionPlan); + return { metadata, result }; +} - const metadataAccount = await fetchMetadata(input.rpc, metadata); - if (!metadataAccount.data.mutable) { +export async function getUpdateMetadataInstructionPlan( + input: Omit & { + metadata: Account; + buffer?: Account; + data?: ReadonlyUint8Array; + payer: TransactionSigner; + planner: TransactionPlanner; + rpc: Rpc; + closeBuffer?: Address | boolean; + } +): Promise { + if (!input.buffer && !input.data) { + throw new Error( + 'Either `buffer` or `data` must be provided to update a metadata account.' + ); + } + if (!input.metadata.data.mutable) { throw new Error('Metadata account is immutable'); } + const data = (input.buffer?.data.data ?? input.data) as ReadonlyUint8Array; + const fullRentPromise = input.rpc + .getMinimumBalanceForRentExemption(getAccountSize(data.length)) + .send(); const sizeDifference = - BigInt(input.data.length) - BigInt(metadataAccount.data.data.length); + BigInt(data.length) - BigInt(input.metadata.data.data.length); const extraRentPromise = sizeDifference > 0 ? input.rpc.getMinimumBalanceForRentExemption(sizeDifference).send() : Promise.resolve(lamports(0n)); - const [extraRent, buffer] = await Promise.all([ + const [fullRent, extraRent, buffer] = await Promise.all([ + fullRentPromise, extraRentPromise, generateKeyPairSigner(), ]); + if (input.buffer) { + return getUpdateMetadataInstructionPlanUsingExistingBuffer({ + ...input, + buffer: input.buffer.address, + extraRent, + metadata: input.metadata.address, + fullRent, + sizeDifference, + }); + } + const extendedInput = { ...input, - programData: isCanonical ? programData : undefined, - metadata, buffer, - bufferRent, + data, extraRent, + fullRent, + metadata: input.metadata.address, sizeDifference, }; - const transactionPlan = await planner( - getUpdateMetadataInstructionPlanUsingInstructionData(extendedInput) - ).catch(() => - planner(getUpdateMetadataInstructionPlanUsingBuffer(extendedInput)) - ); - - const result = await executor(transactionPlan); - return { metadata, result }; + const plan = + getUpdateMetadataInstructionPlanUsingInstructionData(extendedInput); + const validPlan = await isValidInstructionPlan(plan, input.planner); + return validPlan + ? plan + : getUpdateMetadataInstructionPlanUsingNewBuffer(extendedInput); } export function getUpdateMetadataInstructionPlanUsingInstructionData( @@ -131,13 +169,13 @@ export function getUpdateMetadataInstructionPlanUsingInstructionData( ]); } -export function getUpdateMetadataInstructionPlanUsingBuffer( +export function getUpdateMetadataInstructionPlanUsingNewBuffer( input: Omit & { buffer: TransactionSigner; - bufferRent: Lamports; - closeBuffer?: boolean; + closeBuffer?: Address | boolean; data: ReadonlyUint8Array; extraRent: Lamports; + fullRent: Lamports; payer: TransactionSigner; sizeDifference: number | bigint; } @@ -152,22 +190,6 @@ export function getUpdateMetadataInstructionPlanUsingBuffer( }), ] : []), - getCreateAccountInstruction({ - payer: input.payer, - newAccount: input.buffer, - lamports: input.bufferRent, - space: getAccountSize(input.data.length), - programAddress: PROGRAM_METADATA_PROGRAM_ADDRESS, - }), - getAllocateInstruction({ - buffer: input.buffer.address, - authority: input.buffer, - }), - getSetAuthorityInstruction({ - account: input.buffer.address, - authority: input.buffer, - newAuthority: input.authority.address, - }), ...(input.sizeDifference > REALLOC_LIMIT ? [ getExtendInstructionPlan({ @@ -179,13 +201,13 @@ export function getUpdateMetadataInstructionPlanUsingBuffer( }), ] : []), - parallelInstructionPlan([ - getWriteInstructionPlan({ - buffer: input.buffer.address, - authority: input.authority, - data: input.data, - }), - ]), + getCreateBufferInstructionPlan({ + newBuffer: input.buffer, + authority: input.authority, + payer: input.payer, + rent: input.fullRent, + data: input.data, + }), getSetDataInstruction({ ...input, buffer: input.buffer.address, @@ -196,12 +218,75 @@ export function getUpdateMetadataInstructionPlanUsingBuffer( getCloseInstruction({ account: input.buffer.address, authority: input.authority, + destination: + typeof input.closeBuffer === 'string' + ? input.closeBuffer + : input.payer.address, + }), + ] + : []), + ...(input.sizeDifference < 0 + ? [ + getTrimInstruction({ + account: input.metadata, + authority: input.authority, destination: input.payer.address, program: input.program, programData: input.programData, }), ] : []), + ]); +} + +export function getUpdateMetadataInstructionPlanUsingExistingBuffer( + input: Omit & { + buffer: Address; + closeBuffer?: Address | boolean; + extraRent: Lamports; + fullRent: Lamports; + payer: TransactionSigner; + sizeDifference: number | bigint; + } +) { + return sequentialInstructionPlan([ + ...(input.sizeDifference > 0 + ? [ + getTransferSolInstruction({ + source: input.payer, + destination: input.metadata, + amount: input.extraRent, + }), + ] + : []), + ...(input.sizeDifference > REALLOC_LIMIT + ? [ + getExtendInstructionPlan({ + account: input.metadata, + authority: input.authority, + extraLength: Number(input.sizeDifference), + program: input.program, + programData: input.programData, + }), + ] + : []), + getSetDataInstruction({ + ...input, + buffer: input.buffer, + data: undefined, + }), + ...(input.closeBuffer + ? [ + getCloseInstruction({ + account: input.buffer, + authority: input.authority, + destination: + typeof input.closeBuffer === 'string' + ? input.closeBuffer + : input.payer.address, + }), + ] + : []), ...(input.sizeDifference < 0 ? [ getTrimInstruction({ diff --git a/clients/js/src/uploadMetadata.ts b/clients/js/src/uploadMetadata.ts deleted file mode 100644 index ff965a8..0000000 --- a/clients/js/src/uploadMetadata.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - generateKeyPairSigner, - GetAccountInfoApi, - GetMinimumBalanceForRentExemptionApi, - lamports, - Rpc, -} from '@solana/kit'; -import { - getCreateMetadataInstructionPlanUsingBuffer, - getCreateMetadataInstructionPlanUsingInstructionData, -} from './createMetadata'; -import { fetchMaybeMetadata } from './generated'; -import { - createDefaultTransactionPlanExecutor, - createDefaultTransactionPlanner, -} from './instructionPlans'; -import { getPdaDetails } from './internals'; -import { - getUpdateMetadataInstructionPlanUsingBuffer, - getUpdateMetadataInstructionPlanUsingInstructionData, -} from './updateMetadata'; -import { getAccountSize, MetadataInput, MetadataResponse } from './utils'; - -export async function uploadMetadata( - input: MetadataInput & { - rpc: Rpc & - Parameters[0]['rpc']; - rpcSubscriptions: Parameters< - typeof createDefaultTransactionPlanExecutor - >[0]['rpcSubscriptions']; - } -): Promise { - const planner = createDefaultTransactionPlanner({ - feePayer: input.payer, - computeUnitPrice: input.priorityFees, - }); - const executor = createDefaultTransactionPlanExecutor({ - rpc: input.rpc, - rpcSubscriptions: input.rpcSubscriptions, - parallelChunkSize: 5, - }); - - const [{ programData, isCanonical, metadata }, rent] = await Promise.all([ - getPdaDetails(input), - input.rpc - .getMinimumBalanceForRentExemption(getAccountSize(input.data.length)) - .send(), - ]); - - const metadataAccount = await fetchMaybeMetadata(input.rpc, metadata); - - if (!metadataAccount.exists) { - const extendedInput = { - ...input, - programData: isCanonical ? programData : undefined, - metadata, - rent, - }; - - const transactionPlan = await planner( - getCreateMetadataInstructionPlanUsingInstructionData(extendedInput) - ).catch(() => - planner(getCreateMetadataInstructionPlanUsingBuffer(extendedInput)) - ); - - const result = await executor(transactionPlan); - return { metadata, result }; - } - - if (!metadataAccount.data.mutable) { - throw new Error('Metadata account is immutable'); - } - - const sizeDifference = - BigInt(input.data.length) - BigInt(metadataAccount.data.data.length); - const extraRentPromise = - sizeDifference > 0 - ? input.rpc.getMinimumBalanceForRentExemption(sizeDifference).send() - : Promise.resolve(lamports(0n)); - const [extraRent, buffer] = await Promise.all([ - extraRentPromise, - generateKeyPairSigner(), - ]); - - const extendedInput = { - ...input, - programData: isCanonical ? programData : undefined, - metadata, - buffer, - bufferRent: rent, - extraRent, - sizeDifference, - }; - - const transactionPlan = await planner( - getUpdateMetadataInstructionPlanUsingInstructionData(extendedInput) - ).catch(() => - planner(getUpdateMetadataInstructionPlanUsingBuffer(extendedInput)) - ); - - const result = await executor(transactionPlan); - return { metadata, result }; -} diff --git a/clients/js/src/utils.ts b/clients/js/src/utils.ts index 9a22f40..4f97eac 100644 --- a/clients/js/src/utils.ts +++ b/clients/js/src/utils.ts @@ -22,9 +22,16 @@ import { DataSourceArgs, EncodingArgs, FormatArgs, + getExtendInstruction, + getWriteInstruction, SeedArgs, } from './generated'; -import { TransactionPlanResult } from './instructionPlans'; +import { + getLinearIterableInstructionPlan, + getReallocIterableInstructionPlan, + IterableInstructionPlan, + TransactionPlanResult, +} from './instructionPlans'; export const ACCOUNT_HEADER_LENGTH = 96; @@ -46,7 +53,8 @@ export type MetadataInput = { compression: CompressionArgs; format: FormatArgs; dataSource: DataSourceArgs; - data: ReadonlyUint8Array; + data?: ReadonlyUint8Array; + buffer?: Address; /** * Extra fees to pay in microlamports per CU. * Defaults to no extra fees. @@ -54,11 +62,10 @@ export type MetadataInput = { priorityFees?: MicroLamports; /** * When using a buffer, whether to close the buffer account after the operation. - * This is only relevant when updating a metadata account since, when creating - * them, buffer accounts are transformed into metadata accounts. + * If an address is provided, it will be used as the destination for the close instruction. * Defaults to `true`. */ - closeBuffer?: boolean; + closeBuffer?: Address | boolean; }; export type MetadataResponse = { @@ -162,3 +169,40 @@ function getLoaderV3Decoders() { ]), ] as const; } + +export function getExtendInstructionPlan(input: { + account: Address; + authority: TransactionSigner; + extraLength: number; + program?: Address; + programData?: Address; +}): IterableInstructionPlan { + return getReallocIterableInstructionPlan({ + totalSize: input.extraLength, + getInstruction: (size) => + getExtendInstruction({ + account: input.account, + authority: input.authority, + length: size, + program: input.program, + programData: input.programData, + }), + }); +} + +export function getWriteInstructionPlan(input: { + buffer: Address; + authority: TransactionSigner; + data: ReadonlyUint8Array; +}): IterableInstructionPlan { + return getLinearIterableInstructionPlan({ + totalLength: input.data.length, + getInstruction: (offset, length) => + getWriteInstruction({ + buffer: input.buffer, + authority: input.authority, + offset, + data: input.data.slice(offset, offset + length), + }), + }); +} diff --git a/clients/js/src/writeMetadata.ts b/clients/js/src/writeMetadata.ts new file mode 100644 index 0000000..749d723 --- /dev/null +++ b/clients/js/src/writeMetadata.ts @@ -0,0 +1,68 @@ +import { + GetAccountInfoApi, + GetMinimumBalanceForRentExemptionApi, + MaybeAccount, + Rpc, +} from '@solana/kit'; +import { getCreateMetadataInstructionPlan } from './createMetadata'; +import { fetchBuffer, fetchMaybeMetadata, Metadata } from './generated'; +import { + createDefaultTransactionPlanExecutor, + InstructionPlan, +} from './instructionPlans'; +import { + getDefaultTransactionPlannerAndExecutor, + getPdaDetails, +} from './internals'; +import { getUpdateMetadataInstructionPlan } from './updateMetadata'; +import { MetadataInput, MetadataResponse } from './utils'; + +export async function writeMetadata( + input: MetadataInput & { + rpc: Rpc & + Parameters[0]['rpc']; + rpcSubscriptions: Parameters< + typeof createDefaultTransactionPlanExecutor + >[0]['rpcSubscriptions']; + } +): Promise { + const { planner, executor } = getDefaultTransactionPlannerAndExecutor(input); + const { programData, isCanonical, metadata } = await getPdaDetails(input); + const [metadataAccount, buffer] = await Promise.all([ + fetchMaybeMetadata(input.rpc, metadata), + input.buffer + ? fetchBuffer(input.rpc, input.buffer) + : Promise.resolve(undefined), + ]); + + const instructionPlan = await getWriteMetadataInstructionPlan({ + ...input, + buffer, + metadata: metadataAccount, + programData: isCanonical ? programData : undefined, + planner, + }); + + const transactionPlan = await planner(instructionPlan); + const result = await executor(transactionPlan); + return { metadata, result }; +} + +export async function getWriteMetadataInstructionPlan( + input: Omit< + Parameters[0], + 'metadata' + > & { + metadata: MaybeAccount; + } +): Promise { + return input.metadata.exists + ? await getUpdateMetadataInstructionPlan({ + ...input, + metadata: input.metadata, + }) + : await getCreateMetadataInstructionPlan({ + ...input, + metadata: input.metadata.address, + }); +} diff --git a/clients/js/test/_setup.ts b/clients/js/test/_setup.ts index 9268c85..00341c9 100644 --- a/clients/js/test/_setup.ts +++ b/clients/js/test/_setup.ts @@ -46,6 +46,7 @@ import { getExtendInstruction, getInitializeInstruction, getProgramDataPda as getLoaderV3ProgramDataPda, + getSetAuthorityInstruction, getWriteInstruction, InitializeInput, LOADER_V3_PROGRAM_ADDRESS, @@ -330,6 +331,20 @@ export async function createKeypairBuffer( return buffer; } +export async function setAuthority( + client: Client, + input: Parameters[0] & { + payer: TransactionSigner; + } +) { + const setAuthorityIx = getSetAuthorityInstruction(input); + await pipe( + await createDefaultTransaction(client, input.payer), + (tx) => appendTransactionMessageInstructions([setAuthorityIx], tx), + (tx) => signAndSendTransaction(client, tx) + ); +} + type PartialExcept = Partial> & Pick; export async function createMetadata( client: Client, diff --git a/clients/js/test/createMetadata.test.ts b/clients/js/test/createMetadata.test.ts index 0c70f40..340a63c 100644 --- a/clients/js/test/createMetadata.test.ts +++ b/clients/js/test/createMetadata.test.ts @@ -6,6 +6,7 @@ import { createMetadata, DataSource, Encoding, + fetchMaybeBuffer, fetchMetadata, Format, Metadata, @@ -13,7 +14,9 @@ import { import { createDefaultSolanaClient, createDeployedProgram, + createKeypairBuffer, generateKeyPairSignerWithSol, + setAuthority, } from './_setup'; test('it creates a canonical metadata account', async (t) => { @@ -94,6 +97,48 @@ test('it creates a canonical metadata account with data larger than a transactio }); }); +test('it creates a canonical metadata account using an existing buffer', async (t) => { + // Given the following authority and deployed program. + const client = createDefaultSolanaClient(); + const authority = await generateKeyPairSignerWithSol(client); + const [program] = await createDeployedProgram(client, authority); + + // And an existing buffer with the following data. + const data = getUtf8Encoder().encode('{"standard":"dummyIdl"}'); + const buffer = await createKeypairBuffer(client, { payer: authority, data }); + + // When we create a canonical metadata account using the existing buffer. + const { metadata } = await createMetadata({ + ...client, + payer: authority, + authority, + program, + seed: 'idl', + encoding: Encoding.Utf8, + compression: Compression.None, + dataSource: DataSource.Direct, + format: Format.Json, + buffer: buffer.address, + }); + + // Then we expect the following metadata account to be created. + const account = await fetchMetadata(client.rpc, metadata); + t.like(account.data, { + discriminator: AccountDiscriminator.Metadata, + program, + authority: none(), + mutable: true, + canonical: true, + seed: 'idl', + encoding: Encoding.Utf8, + compression: Compression.None, + format: Format.Json, + dataSource: DataSource.Direct, + dataLength: data.length, + data, + }); +}); + test('it creates a non-canonical metadata account', async (t) => { // Given the following authority and deployed program. const client = createDefaultSolanaClient(); @@ -171,3 +216,108 @@ test('it creates a non-canonical metadata account with data larger than a transa data: largeData, }); }); + +test('it creates a non-canonical metadata account using an existing buffer', async (t) => { + // Given the following authority and deployed program. + const client = createDefaultSolanaClient(); + const authority = await generateKeyPairSignerWithSol(client); + const program = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); + + // And an existing buffer with the following data. + const data = getUtf8Encoder().encode('{"standard":"dummyIdl"}'); + const buffer = await createKeypairBuffer(client, { payer: authority, data }); + + // When we create a non-canonical metadata account using the existing buffer. + const { metadata } = await createMetadata({ + ...client, + payer: authority, + authority, + program, + seed: 'idl', + encoding: Encoding.Utf8, + compression: Compression.None, + dataSource: DataSource.Direct, + format: Format.Json, + buffer: buffer.address, + }); + + // Then we expect the following metadata account to be created. + const account = await fetchMetadata(client.rpc, metadata); + t.like(account.data, { + discriminator: AccountDiscriminator.Metadata, + program, + authority: some(authority.address), + mutable: true, + canonical: false, + seed: 'idl', + encoding: Encoding.Utf8, + compression: Compression.None, + format: Format.Json, + dataSource: DataSource.Direct, + dataLength: data.length, + data, + }); +}); + +test('it cannot create a metadata account if no data or buffer is provided', async (t) => { + // Given the following authority and deployed program. + const client = createDefaultSolanaClient(); + const authority = await generateKeyPairSignerWithSol(client); + const program = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); + + // When we try to create a metadata account without providing data or buffer. + const promise = createMetadata({ + ...client, + payer: authority, + authority, + program, + seed: 'idl', + encoding: Encoding.Utf8, + compression: Compression.None, + dataSource: DataSource.Direct, + format: Format.Json, + }); + + // Then we expect the following error to be thrown. + await t.throwsAsync(promise, { + message: + 'Either `buffer` or `data` must be provided to create a new metadata account.', + }); +}); + +test('it can close an existing buffer after using it to create a new metadata account', async (t) => { + // Given the following authority and deployed program. + const client = createDefaultSolanaClient(); + const authority = await generateKeyPairSignerWithSol(client); + const [program] = await createDeployedProgram(client, authority); + + // And an existing buffer with the same authority. + const data = getUtf8Encoder().encode('{"standard":"dummyIdl"}'); + const buffer = await createKeypairBuffer(client, { payer: authority, data }); + await setAuthority(client, { + payer: authority, + authority: buffer, + account: buffer.address, + newAuthority: authority.address, + }); + + // When we create a canonical metadata account + // using the existing buffer and the `closeBuffer` option. + await createMetadata({ + ...client, + payer: authority, + authority, + program, + seed: 'idl', + encoding: Encoding.Utf8, + compression: Compression.None, + dataSource: DataSource.Direct, + format: Format.Json, + buffer: buffer.address, + closeBuffer: true, + }); + + // Then we expect the buffer account to no longer exist. + const bufferAccount = await fetchMaybeBuffer(client.rpc, buffer.address); + t.false(bufferAccount.exists); +}); diff --git a/clients/js/test/downloadMetadata.test.ts b/clients/js/test/fetchMetadataContent.test.ts similarity index 89% rename from clients/js/test/downloadMetadata.test.ts rename to clients/js/test/fetchMetadataContent.test.ts index c8b96da..3f36480 100644 --- a/clients/js/test/downloadMetadata.test.ts +++ b/clients/js/test/fetchMetadataContent.test.ts @@ -1,10 +1,10 @@ import { address } from '@solana/kit'; import test from 'ava'; import { - downloadAndParseMetadata, + fetchAndParseMetadataContent, Format, packDirectData, - uploadMetadata, + writeMetadata, } from '../src'; import { createDefaultSolanaClient, @@ -20,7 +20,7 @@ test('it fetches and parses direct IDLs from canonical metadata accounts', async // And given the following IDL exists for the program. const idl = '{"kind":"rootNode","standard":"codama","version":"1.0.0"}'; - await uploadMetadata({ + await writeMetadata({ ...client, ...packDirectData({ content: idl }), payer: authority, @@ -31,7 +31,7 @@ test('it fetches and parses direct IDLs from canonical metadata accounts', async }); // When we fetch the canonical IDL for the program. - const result = await downloadAndParseMetadata(client.rpc, program, 'idl'); + const result = await fetchAndParseMetadataContent(client.rpc, program, 'idl'); // Then we expect the following IDL to be fetched and parsed. t.deepEqual(result, { @@ -49,7 +49,7 @@ test('it fetches and parses direct IDLs from non-canonical metadata accounts', a // And given the following IDL exists for the program. const idl = '{"kind":"rootNode","standard":"codama","version":"1.0.0"}'; - await uploadMetadata({ + await writeMetadata({ ...client, ...packDirectData({ content: idl }), payer: authority, @@ -60,7 +60,7 @@ test('it fetches and parses direct IDLs from non-canonical metadata accounts', a }); // When we fetch the non-canonical IDL for the program. - const result = await downloadAndParseMetadata( + const result = await fetchAndParseMetadataContent( client.rpc, program, 'idl', diff --git a/clients/js/test/updateMetadata.test.ts b/clients/js/test/updateMetadata.test.ts index e5d8802..c7885ee 100644 --- a/clients/js/test/updateMetadata.test.ts +++ b/clients/js/test/updateMetadata.test.ts @@ -6,6 +6,7 @@ import { createMetadata, DataSource, Encoding, + fetchMaybeBuffer, fetchMetadata, Format, Metadata, @@ -14,7 +15,9 @@ import { import { createDefaultSolanaClient, createDeployedProgram, + createKeypairBuffer, generateKeyPairSignerWithSol, + setAuthority, } from './_setup'; test('it updates a canonical metadata account', async (t) => { @@ -123,6 +126,65 @@ test('it updates a canonical metadata account with data larger than a transactio }); }); +test('it updates a canonical metadata account using an existing buffer', async (t) => { + // Given the following authority and deployed program. + const client = createDefaultSolanaClient(); + const authority = await generateKeyPairSignerWithSol(client); + const [program] = await createDeployedProgram(client, authority); + + // And the following existing canonical metadata account. + await createMetadata({ + ...client, + payer: authority, + authority, + program, + seed: 'idl', + encoding: Encoding.Utf8, + compression: Compression.None, + dataSource: DataSource.Direct, + format: Format.Json, + data: getUtf8Encoder().encode('OLD'), + }); + + // And an existing buffer with the following data. + const newData = getUtf8Encoder().encode('NEW DATA WITH MORE BYTES'); + const buffer = await createKeypairBuffer(client, { + payer: authority, + data: newData, + }); + + // When we update the metadata account using the existing buffer. + const { metadata } = await updateMetadata({ + ...client, + payer: authority, + authority, + program, + seed: 'idl', + encoding: Encoding.Base58, + compression: Compression.Gzip, + dataSource: DataSource.Url, + format: Format.Toml, + buffer: buffer.address, + }); + + // Then we expect the metadata account to be updated. + const account = await fetchMetadata(client.rpc, metadata); + t.like(account.data, { + discriminator: AccountDiscriminator.Metadata, + program, + authority: none(), + mutable: true, + canonical: true, + seed: 'idl', + encoding: Encoding.Base58, + compression: Compression.Gzip, + dataSource: DataSource.Url, + format: Format.Toml, + dataLength: newData.length, + data: newData, + }); +}); + test('it updates a non-canonical metadata account', async (t) => { // Given the following authority and deployed program. const client = createDefaultSolanaClient(); @@ -228,3 +290,210 @@ test('it updates a non-canonical metadata account with data larger than a transa data: newData, }); }); + +test('it updates a non-canonical metadata account using an existing buffer', async (t) => { + // Given the following authority and deployed program. + const client = createDefaultSolanaClient(); + const authority = await generateKeyPairSignerWithSol(client); + const program = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); + + // And the following existing non-canonical metadata account. + await createMetadata({ + ...client, + payer: authority, + authority, + program, + seed: 'idl', + encoding: Encoding.Utf8, + compression: Compression.None, + dataSource: DataSource.Direct, + format: Format.Json, + data: getUtf8Encoder().encode('OLD'), + }); + + // And an existing buffer with the following data. + const newData = getUtf8Encoder().encode('NEW DATA WITH MORE BYTES'); + const buffer = await createKeypairBuffer(client, { + payer: authority, + data: newData, + }); + + // When we update the metadata account using the existing buffer. + const { metadata } = await updateMetadata({ + ...client, + payer: authority, + authority, + program, + seed: 'idl', + encoding: Encoding.Base58, + compression: Compression.Gzip, + dataSource: DataSource.Url, + format: Format.Toml, + buffer: buffer.address, + }); + + // Then we expect the metadata account to be updated. + const account = await fetchMetadata(client.rpc, metadata); + t.like(account.data, { + discriminator: AccountDiscriminator.Metadata, + program, + authority: some(authority.address), + mutable: true, + canonical: false, + seed: 'idl', + encoding: Encoding.Base58, + compression: Compression.Gzip, + dataSource: DataSource.Url, + format: Format.Toml, + dataLength: newData.length, + data: newData, + }); +}); + +test('it cannot update a metadata account if no data or buffer is provided', async (t) => { + // Given the following authority and deployed program. + const client = createDefaultSolanaClient(); + const authority = await generateKeyPairSignerWithSol(client); + const program = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); + + // And the following existing canonical metadata account. + await createMetadata({ + ...client, + payer: authority, + authority, + program, + seed: 'idl', + encoding: Encoding.Utf8, + compression: Compression.None, + dataSource: DataSource.Direct, + format: Format.Json, + data: getUtf8Encoder().encode('OLD'), + }); + + // When we try to update a metadata account without providing data or buffer. + const promise = updateMetadata({ + ...client, + payer: authority, + authority, + program, + seed: 'idl', + encoding: Encoding.Utf8, + compression: Compression.None, + dataSource: DataSource.Direct, + format: Format.Json, + }); + + // Then we expect the following error to be thrown. + await t.throwsAsync(promise, { + message: + 'Either `buffer` or `data` must be provided to update a metadata account.', + }); +}); + +test('it can close a new buffer after using it to update a new metadata account', async (t) => { + // Given the following authority and deployed program. + const client = createDefaultSolanaClient(); + const authority = await generateKeyPairSignerWithSol(client); + const [program] = await createDeployedProgram(client, authority); + + // And the following existing canonical metadata account. + await createMetadata({ + ...client, + payer: authority, + authority, + program, + seed: 'idl', + encoding: Encoding.Utf8, + compression: Compression.None, + dataSource: DataSource.Direct, + format: Format.Json, + data: getUtf8Encoder().encode('OLD'), + }); + + // When we update the metadata account using the close buffer. + const newData = getUtf8Encoder().encode('x'.repeat(3_000)); + const { metadata } = await updateMetadata({ + ...client, + payer: authority, + authority, + program, + seed: 'idl', + encoding: Encoding.Base58, + compression: Compression.Gzip, + dataSource: DataSource.Url, + format: Format.Toml, + data: newData, + closeBuffer: true, + }); + + // Then we expect the metadata account to be updated. + const account = await fetchMetadata(client.rpc, metadata); + t.like(account.data, { + discriminator: AccountDiscriminator.Metadata, + program, + authority: none(), + mutable: true, + canonical: true, + seed: 'idl', + encoding: Encoding.Base58, + compression: Compression.Gzip, + dataSource: DataSource.Url, + format: Format.Toml, + dataLength: newData.length, + data: newData, + }); +}); + +test('it can close an existing buffer after using it to update a new metadata account', async (t) => { + // Given the following authority and deployed program. + const client = createDefaultSolanaClient(); + const authority = await generateKeyPairSignerWithSol(client); + const [program] = await createDeployedProgram(client, authority); + + // And the following existing canonical metadata account. + await createMetadata({ + ...client, + payer: authority, + authority, + program, + seed: 'idl', + encoding: Encoding.Utf8, + compression: Compression.None, + dataSource: DataSource.Direct, + format: Format.Json, + data: getUtf8Encoder().encode('OLD'), + }); + + // And an existing buffer with the following data and the same authority. + const newData = getUtf8Encoder().encode('NEW DATA WITH MORE BYTES'); + const buffer = await createKeypairBuffer(client, { + payer: authority, + data: newData, + }); + await setAuthority(client, { + payer: authority, + account: buffer.address, + authority: buffer, + newAuthority: authority.address, + }); + + // When we update the metadata account using + // the existing buffer and the `closeBuffer` option. + await updateMetadata({ + ...client, + payer: authority, + authority, + program, + seed: 'idl', + encoding: Encoding.Base58, + compression: Compression.Gzip, + dataSource: DataSource.Url, + format: Format.Toml, + buffer: buffer.address, + closeBuffer: true, + }); + + // Then we expect the buffer account to no longer exist. + const bufferAccount = await fetchMaybeBuffer(client.rpc, buffer.address); + t.false(bufferAccount.exists); +}); diff --git a/clients/js/test/uploadMetadata.test.ts b/clients/js/test/writeMetadata.test.ts similarity index 97% rename from clients/js/test/uploadMetadata.test.ts rename to clients/js/test/writeMetadata.test.ts index 92e6251..71962be 100644 --- a/clients/js/test/uploadMetadata.test.ts +++ b/clients/js/test/writeMetadata.test.ts @@ -10,7 +10,7 @@ import { findCanonicalPda, Format, Metadata, - uploadMetadata, + writeMetadata, } from '../src'; import { createDefaultSolanaClient, @@ -30,7 +30,7 @@ test('it creates a new metadata account if it does not exist', async (t) => { // When we upload this canonical metadata account. const data = getUtf8Encoder().encode('Some data'); - await uploadMetadata({ + await writeMetadata({ ...client, payer: authority, authority, @@ -83,7 +83,7 @@ test('it updates a metadata account if it exists', async (t) => { // When we upload this canonical metadata account with different data. const newData = getUtf8Encoder().encode('NEW DATA WITH MORE BYTES'); - const { metadata } = await uploadMetadata({ + const { metadata } = await writeMetadata({ ...client, payer: authority, authority, diff --git a/clients/js/tsup.config.ts b/clients/js/tsup.config.ts index 96695b2..8bcc7a0 100644 --- a/clients/js/tsup.config.ts +++ b/clients/js/tsup.config.ts @@ -16,7 +16,7 @@ export default defineConfig(() => [ { ...SHARED_OPTIONS, format: 'esm' }, // CLI. - { ...SHARED_OPTIONS, format: 'cjs', entry: ['./src/cli.ts'] }, + { ...SHARED_OPTIONS, format: 'cjs', entry: { cli: './src/cli/index.ts' } }, // Tests. {