From 5bfa8ba32d926493f80f30d3439c6e0c8123d592 Mon Sep 17 00:00:00 2001 From: MarkSackerberg <93528482+MarkSackerberg@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:52:55 +0200 Subject: [PATCH 1/4] Add candy machine guard management commands Add cm guard update, remove, and delete commands: - cm guard update: update guards from cm-config.json or via interactive wizard - cm guard remove: unwrap candy guard (return mint authority to CM authority) - cm guard delete: permanently delete candy guard account and reclaim rent Includes tests for all commands covering success and error paths. --- src/commands/cm/guard/delete.ts | 95 +++++++++ src/commands/cm/guard/index.ts | 21 ++ src/commands/cm/guard/remove.ts | 130 ++++++++++++ src/commands/cm/guard/update.ts | 329 ++++++++++++++++++++++++++++++ src/commands/cm/index.ts | 18 +- test/commands/cm/cm.guard.test.ts | 228 +++++++++++++++++++++ 6 files changed, 815 insertions(+), 6 deletions(-) create mode 100644 src/commands/cm/guard/delete.ts create mode 100644 src/commands/cm/guard/index.ts create mode 100644 src/commands/cm/guard/remove.ts create mode 100644 src/commands/cm/guard/update.ts create mode 100644 test/commands/cm/cm.guard.test.ts diff --git a/src/commands/cm/guard/delete.ts b/src/commands/cm/guard/delete.ts new file mode 100644 index 00000000..13460d18 --- /dev/null +++ b/src/commands/cm/guard/delete.ts @@ -0,0 +1,95 @@ +import { input } from '@inquirer/prompts' +import { + fetchCandyGuard, + deleteCandyGuard, +} from '@metaplex-foundation/mpl-core-candy-machine' +import { publicKey } from '@metaplex-foundation/umi' +import { Flags } from '@oclif/core' +import ora from 'ora' +import { TransactionCommand } from '../../../TransactionCommand.js' +import { terminalColors } from '../../../lib/StandardColors.js' +import umiSendAndConfirmTransaction from '../../../lib/umi/sendAndConfirm.js' + +export default class CmGuardDelete extends TransactionCommand { + static override description = `Delete a candy guard account and reclaim rent + + The candy guard must first be removed (unwrapped) from the candy machine + before it can be deleted. Use 'cm guard remove' first if needed. + + ⚠️ WARNING: This permanently deletes the candy guard account. This cannot be undone. + ` + + static override examples = [ + '$ mplx cm guard delete --address ', + '$ mplx cm guard delete --address --force', + ] + + static override usage = 'cm guard delete [FLAGS]' + + static override flags = { + address: Flags.string({ + char: 'a', + description: 'The address of the candy guard to delete', + required: true, + }), + force: Flags.boolean({ + description: 'Skip confirmation prompt', + default: false, + }), + } + + public async run(): Promise { + const { flags } = await this.parse(CmGuardDelete) + const { umi } = this.context + + const candyGuardAddress = flags.address + + // Verify the candy guard exists + const verifySpinner = ora('Verifying candy guard...').start() + + try { + await fetchCandyGuard(umi, publicKey(candyGuardAddress)) + verifySpinner.succeed(`Found candy guard: ${candyGuardAddress}`) + } catch (error) { + verifySpinner.fail('Candy guard not found') + this.error(`The account at ${candyGuardAddress} does not exist or is not a valid candy guard.`) + } + + // Confirmation + if (!flags.force) { + this.log(`\n${terminalColors.BgRed}${terminalColors.FgWhite}You are about to permanently delete this candy guard account${terminalColors.FgDefault}${terminalColors.BgDefault}`) + this.log(`Candy guard: ${candyGuardAddress}`) + this.log(`\nThis action cannot be undone. The rent will be returned to your wallet.\n`) + + await input({ + message: `Type 'yes-delete' to confirm`, + validate: (val) => { + if (val === 'yes-delete') return true + return 'Please type "yes-delete" to confirm' + } + }) + } + + // Delete the candy guard + const deleteSpinner = ora('Deleting candy guard...').start() + + try { + const tx = deleteCandyGuard(umi, { + candyGuard: publicKey(candyGuardAddress), + }) + + await umiSendAndConfirmTransaction(umi, tx) + deleteSpinner.succeed('Candy guard deleted successfully') + } catch (error) { + deleteSpinner.fail('Failed to delete candy guard') + this.error(`Delete failed: ${error instanceof Error ? error.message : String(error)}`) + } + + this.log(`Rent has been returned to your wallet.`) + this.logSuccess('Candy guard deleted!') + + return { + candyGuardAddress, + } + } +} diff --git a/src/commands/cm/guard/index.ts b/src/commands/cm/guard/index.ts new file mode 100644 index 00000000..4023dc91 --- /dev/null +++ b/src/commands/cm/guard/index.ts @@ -0,0 +1,21 @@ +import { Command } from '@oclif/core' + +export default class CmGuard extends Command { + static override description = 'Manage candy machine guards' + + static override examples = [ + '<%= config.bin %> <%= command.id %> update', + '<%= config.bin %> <%= command.id %> remove', + '<%= config.bin %> <%= command.id %> delete', + ] + + public async run(): Promise { + this.log('Available candy machine guard commands:') + this.log(' update - Update guards on a candy machine') + this.log(' remove - Remove (unwrap) the candy guard from a candy machine') + this.log(' delete - Delete a candy guard account and reclaim rent') + this.log('') + this.log('Use --help with any command for more details') + this.log('Example: mplx cm guard update --help') + } +} diff --git a/src/commands/cm/guard/remove.ts b/src/commands/cm/guard/remove.ts new file mode 100644 index 00000000..09993157 --- /dev/null +++ b/src/commands/cm/guard/remove.ts @@ -0,0 +1,130 @@ +import { input } from '@inquirer/prompts' +import { + fetchCandyMachine, + fetchCandyGuard, + unwrap, +} from '@metaplex-foundation/mpl-core-candy-machine' +import { publicKey } from '@metaplex-foundation/umi' +import { Flags } from '@oclif/core' +import ora from 'ora' +import { TransactionCommand } from '../../../TransactionCommand.js' +import { terminalColors } from '../../../lib/StandardColors.js' +import { readCmConfig } from '../../../lib/cm/cm-utils.js' +import umiSendAndConfirmTransaction from '../../../lib/umi/sendAndConfirm.js' + +export default class CmGuardRemove extends TransactionCommand { + static override description = `Remove (unwrap) the candy guard from a candy machine + + This changes the mint authority back to the candy machine authority, + meaning minting will require the authority to sign instead of using guards. + The candy guard account is NOT deleted and can be re-attached later. + Use 'cm guard delete' to permanently delete the candy guard account. + ` + + static override examples = [ + '$ mplx cm guard remove', + '$ mplx cm guard remove --address ', + '$ mplx cm guard remove --force', + ] + + static override usage = 'cm guard remove [FLAGS]' + + static override flags = { + address: Flags.string({ + char: 'a', + description: 'The address of the candy machine', + required: false, + }), + force: Flags.boolean({ + description: 'Skip confirmation prompt', + default: false, + }), + } + + public async run(): Promise { + const { flags } = await this.parse(CmGuardRemove) + const { umi } = this.context + + // Resolve candy machine address + let candyMachineAddress = flags.address + + if (!candyMachineAddress) { + try { + const config = readCmConfig() + candyMachineAddress = config.candyMachineId + } catch { + // no config file found + } + } + + if (!candyMachineAddress) { + this.error('No candy machine address provided. Use --address or run from a directory with cm-config.json') + } + + // Fetch candy machine to find its candy guard + const fetchSpinner = ora('Fetching candy machine...').start() + let candyGuardAddress: string + + try { + const candyMachine = await fetchCandyMachine(umi, publicKey(candyMachineAddress)) + + if (candyMachine.mintAuthority === candyMachine.authority) { + fetchSpinner.fail('Candy machine already uses authority-only minting') + this.error('This candy machine does not have a candy guard attached. Nothing to remove.') + } + + candyGuardAddress = candyMachine.mintAuthority + + // Verify it's actually a candy guard + await fetchCandyGuard(umi, publicKey(candyGuardAddress)) + fetchSpinner.succeed(`Found candy guard: ${candyGuardAddress}`) + } catch (error) { + if (error instanceof Error && error.message.includes('authority-only')) { + throw error + } + fetchSpinner.fail('Failed to fetch candy machine or candy guard') + this.error(`Failed: ${error instanceof Error ? error.message : String(error)}`) + } + + // Confirmation + if (!flags.force) { + this.log(`\n${terminalColors.BgRed}${terminalColors.FgWhite}You are about to remove the candy guard from this candy machine${terminalColors.FgDefault}${terminalColors.BgDefault}`) + this.log(`Candy machine: ${candyMachineAddress}`) + this.log(`Candy guard: ${candyGuardAddress}`) + this.log(`\nAfter removal, minting will require the authority to sign directly.`) + this.log(`The candy guard account will still exist and can be re-attached.\n`) + + await input({ + message: `Type 'yes-remove' to confirm`, + validate: (val) => { + if (val === 'yes-remove') return true + return 'Please type "yes-remove" to confirm' + } + }) + } + + // Unwrap the candy guard + const unwrapSpinner = ora('Removing candy guard...').start() + + try { + const tx = unwrap(umi, { + candyGuard: publicKey(candyGuardAddress), + candyMachine: publicKey(candyMachineAddress), + }) + + await umiSendAndConfirmTransaction(umi, tx) + unwrapSpinner.succeed('Candy guard removed successfully') + } catch (error) { + unwrapSpinner.fail('Failed to remove candy guard') + this.error(`Remove failed: ${error instanceof Error ? error.message : String(error)}`) + } + + this.log(`Mint authority has been returned to the candy machine authority.`) + this.logSuccess('Candy guard removed!') + + return { + candyMachineAddress, + candyGuardAddress, + } + } +} diff --git a/src/commands/cm/guard/update.ts b/src/commands/cm/guard/update.ts new file mode 100644 index 00000000..e27c62f4 --- /dev/null +++ b/src/commands/cm/guard/update.ts @@ -0,0 +1,329 @@ +import { + fetchCandyMachine, + fetchCandyGuard, + updateCandyGuard, +} from '@metaplex-foundation/mpl-core-candy-machine' +import { publicKey } from '@metaplex-foundation/umi' +import { Args, Flags } from '@oclif/core' +import { checkbox, input } from '@inquirer/prompts' +import ora from 'ora' +import { TransactionCommand } from '../../../TransactionCommand.js' +import { readCmConfig, writeCmConfig } from '../../../lib/cm/cm-utils.js' +import { candyGuardsSchema } from '../../../lib/cm/candyGuardsSchema.js' +import jsonGuardParser from '../../../lib/cm/jsonGuardParser.js' +import { CandyMachineConfig, RawGuardConfig } from '../../../lib/cm/types.js' +import promptSelector from '../../../prompts/promptSelector.js' +import umiSendAndConfirmTransaction from '../../../lib/umi/sendAndConfirm.js' + +export default class CmGuardUpdate extends TransactionCommand { + static override description = `Update the guards on a candy machine's candy guard + + Reads guard configuration from cm-config.json in the current directory. + Use --wizard for an interactive setup process. + The candy machine address is read from cm-config.json or provided via --address. + ` + + static override examples = [ + '$ mplx cm guard update', + '$ mplx cm guard update ', + '$ mplx cm guard update --address ', + '$ mplx cm guard update --wizard', + ] + + static override usage = 'cm guard update [DIRECTORY] [FLAGS]' + + static override args = { + directory: Args.string({ + description: 'The directory containing the cm-config.json file', + required: false, + }), + } + + static override flags = { + address: Flags.string({ + char: 'a', + description: 'The address of the candy machine', + required: false, + }), + wizard: Flags.boolean({ + description: 'Use interactive wizard to configure guards', + required: false, + }), + } + + public async run(): Promise { + const { flags, args } = await this.parse(CmGuardUpdate) + const { umi } = this.context + const directory = args.directory + + // Resolve candy machine address + let candyMachineAddress = flags.address + let cmConfig: CandyMachineConfig | undefined + + try { + cmConfig = readCmConfig(directory) + } catch { + // no config file found + } + + if (!candyMachineAddress) { + candyMachineAddress = cmConfig?.candyMachineId + } + + if (!candyMachineAddress) { + this.error('No candy machine address provided. Use --address or run from a directory with cm-config.json') + } + + // Load guard configuration + let guardConfig: CandyMachineConfig['config']['guardConfig'] + let groups: CandyMachineConfig['config']['groups'] + + if (flags.wizard) { + const result = await this.runWizard(cmConfig) + guardConfig = result.guardConfig + groups = result.groups + + // Save updated config if we have one + if (cmConfig) { + cmConfig.config.guardConfig = guardConfig + cmConfig.config.groups = groups + writeCmConfig(cmConfig, directory) + this.log('Updated cm-config.json with new guard configuration') + } + } else { + if (!cmConfig) { + this.error('No cm-config.json found. Run from a directory with cm-config.json or use --wizard') + } + guardConfig = cmConfig.config.guardConfig + groups = cmConfig.config.groups + } + + if ((!guardConfig || Object.keys(guardConfig).length === 0) && (!groups || groups.length === 0)) { + this.error('No guards or groups found in configuration. Nothing to update.') + } + + // Parse guards using existing parser + const parsedGuards = jsonGuardParser({ + name: '', + config: { + collection: '', + itemsAvailable: 0, + isMutable: false, + isSequential: false, + guardConfig, + groups, + } + }) + + // Fetch candy machine to find its candy guard + const fetchSpinner = ora('Fetching candy machine...').start() + let candyGuardAddress: string + + try { + const candyMachine = await fetchCandyMachine(umi, publicKey(candyMachineAddress)) + + if (candyMachine.mintAuthority === candyMachine.authority) { + fetchSpinner.fail('Candy machine uses authority-only minting (no candy guard)') + this.error('This candy machine does not have a candy guard. Guards can only be updated on candy machines with an associated candy guard.') + } + + // The mint authority is the candy guard address + candyGuardAddress = candyMachine.mintAuthority + + // Verify it's actually a candy guard + await fetchCandyGuard(umi, publicKey(candyGuardAddress)) + fetchSpinner.succeed(`Found candy guard: ${candyGuardAddress}`) + } catch (error) { + if (error instanceof Error && error.message.includes('authority-only')) { + throw error + } + fetchSpinner.fail('Failed to fetch candy machine or candy guard') + this.error(`Failed: ${error instanceof Error ? error.message : String(error)}`) + } + + // Update the candy guard + const updateSpinner = ora('Updating candy guard...').start() + + try { + const tx = updateCandyGuard(umi, { + candyGuard: publicKey(candyGuardAddress), + guards: parsedGuards.guards, + groups: parsedGuards.groups, + }) + + await umiSendAndConfirmTransaction(umi, tx) + updateSpinner.succeed('Candy guard updated successfully') + } catch (error) { + updateSpinner.fail('Failed to update candy guard') + this.error(`Update failed: ${error instanceof Error ? error.message : String(error)}`) + } + + // Log summary + const globalGuards = guardConfig ? Object.keys(guardConfig as Record) : [] + const groupLabels = groups ? groups.map(g => g.label) : [] + + if (globalGuards.length > 0) { + this.log(`Global guards: ${globalGuards.join(', ')}`) + } + if (groupLabels.length > 0) { + this.log(`Guard groups: ${groupLabels.join(', ')}`) + } + + this.logSuccess('Guard update complete!') + + return { + candyMachineAddress, + candyGuardAddress, + guards: globalGuards, + groups: groupLabels, + } + } + + private async runWizard(existingConfig?: CandyMachineConfig): Promise<{ + guardConfig: RawGuardConfig | undefined, + groups: CandyMachineConfig['config']['groups'] + }> { + this.log( + `-------------------------------- + + Candy Guard Update Wizard + + This wizard will guide you through configuring guards for your candy machine. + Note: This will replace ALL existing guards with the new configuration. + +--------------------------------` + ) + + function checkAbort(val: any) { + if (typeof val === 'string' && val.trim().toLowerCase() === 'q') { + console.log('Aborting wizard by user request.') + process.exit(0) + } + if (Array.isArray(val) && val.includes('Quit')) { + console.log('Aborting wizard by user request.') + process.exit(0) + } + } + + // Show existing guards if available + if (existingConfig) { + const existingGuards = existingConfig.config.guardConfig + const existingGroups = existingConfig.config.groups + const hasExistingGuards = existingGuards && Object.keys(existingGuards as Record).length > 0 + const hasExistingGroups = existingGroups && existingGroups.length > 0 + + if (hasExistingGuards || hasExistingGroups) { + this.log('\nCurrent guard configuration:') + if (hasExistingGuards) { + this.log(` Global guards: ${Object.keys(existingGuards as Record).join(', ')}`) + } + if (hasExistingGroups) { + for (const group of existingGroups) { + this.log(` Group "${group.label}": ${Object.keys(group.guards as Record).join(', ')}`) + } + } + this.log('') + } + } + + const guardConfig: Record = {} + const groups: CandyMachineConfig['config']['groups'] = [] + + const guardChoices = Object.entries(candyGuardsSchema).map(([guard]) => guard).sort() + + // Global guards + const globalGuardsPrompt = await input({ + message: 'Do you want to configure global guards? (y/n or q to quit)', + validate: () => true, + }) + checkAbort(globalGuardsPrompt) + + if (globalGuardsPrompt.trim().toLowerCase() === 'y') { + const selectedGlobalGuards: string[] = await checkbox({ + message: 'Select the guards to assign globally:', + choices: [...guardChoices, 'Quit'], + pageSize: 20, + loop: false, + }) + checkAbort(selectedGlobalGuards) + + for (const guard of selectedGlobalGuards) { + console.log(`Configuring guard: ${guard}`) + const answers: { [key: string]: string | number | boolean | any[] } = {} + const promptItem = candyGuardsSchema[guard as keyof typeof candyGuardsSchema] + for (const prompt of promptItem) { + const res = await promptSelector(prompt) + answers[prompt.name] = res as string | number | boolean + } + ;(guardConfig as any)[guard] = answers + } + } + + // Guard groups + const enableGroupsPrompt = await input({ + message: 'Do you want to configure guard groups? (y/n or q to quit)', + validate: () => true, + }) + checkAbort(enableGroupsPrompt) + + if (enableGroupsPrompt.trim().toLowerCase() === 'y') { + const numGroupsPrompt = await input({ + message: 'Enter the number of groups (or q to quit):', + validate: () => true, + }) + checkAbort(numGroupsPrompt) + const numGroups = Number(numGroupsPrompt) + + for (let i = 0; i < numGroups; i++) { + const groupName = await input({ + message: `Enter the name of group ${i + 1} (max 6 chars, or q to quit):`, + validate: (value) => { + if (value === 'q') return true + if (value.length > 6) return 'Group label must be 6 characters or less' + if (!value) return 'Group name is required' + return true + }, + }) + checkAbort(groupName) + + const groupGuards: Record = {} + + const selectedGuards: string[] = await checkbox({ + message: `Select the guards to assign to group "${groupName}":`, + choices: [...guardChoices, 'Quit'], + pageSize: 20, + loop: false, + }) + checkAbort(selectedGuards) + + for (const selectedGuard of selectedGuards) { + console.log(`Configuring guard: ${selectedGuard}`) + const answers: { [key: string]: string | number | boolean | any[] } = {} + const promptItem = candyGuardsSchema[selectedGuard as keyof typeof candyGuardsSchema] + for (const prompt of promptItem) { + const res = await promptSelector(prompt) + answers[prompt.name] = res as string | number | boolean + } + ;(groupGuards as any)[selectedGuard] = answers + } + + groups.push({ + label: groupName, + guards: groupGuards, + }) + } + } + + const hasGlobalGuards = Object.keys(guardConfig).length > 0 + const hasGroups = groups.length > 0 + + if (!hasGlobalGuards && !hasGroups) { + this.log('⚠️ Warning: No guards or groups configured. This will remove all existing guards from the candy machine.') + } + + return { + guardConfig: hasGlobalGuards ? guardConfig as RawGuardConfig : undefined, + groups: hasGroups ? groups : undefined, + } + } +} diff --git a/src/commands/cm/index.ts b/src/commands/cm/index.ts index f26414a4..220d9ede 100644 --- a/src/commands/cm/index.ts +++ b/src/commands/cm/index.ts @@ -10,18 +10,24 @@ export default class Cm extends Command { '<%= config.bin %> <%= command.id %> insert', '<%= config.bin %> <%= command.id %> fetch', '<%= config.bin %> <%= command.id %> withdraw', + '<%= config.bin %> <%= command.id %> guard update', + '<%= config.bin %> <%= command.id %> guard remove', + '<%= config.bin %> <%= command.id %> guard delete', ] public async run(): Promise { // This command acts as a namespace for subcommands // Users should use specific subcommands like 'create', 'upload', etc. this.log('Available candy machine commands:') - this.log(' create - Create a new candy machine') - this.log(' upload - Upload assets to storage') - this.log(' validate - Validate assets and configuration') - this.log(' insert - Insert items into candy machine') - this.log(' fetch - Fetch candy machine information') - this.log(' withdraw - Withdraw and delete candy machine') + this.log(' create - Create a new candy machine') + this.log(' upload - Upload assets to storage') + this.log(' validate - Validate assets and configuration') + this.log(' insert - Insert items into candy machine') + this.log(' fetch - Fetch candy machine information') + this.log(' withdraw - Withdraw and delete candy machine') + this.log(' guard update - Update guards on a candy machine') + this.log(' guard remove - Remove (unwrap) candy guard from a candy machine') + this.log(' guard delete - Delete a candy guard account and reclaim rent') this.log('') this.log('Use --help with any command for more details') this.log('Example: mplx cm create --help') diff --git a/test/commands/cm/cm.guard.test.ts b/test/commands/cm/cm.guard.test.ts new file mode 100644 index 00000000..7ad6715c --- /dev/null +++ b/test/commands/cm/cm.guard.test.ts @@ -0,0 +1,228 @@ +import { exec } from 'node:child_process' +import { runCli } from '../../runCli' +import { promisify } from 'node:util' +import { createCoreCollection } from '../core/corehelpers' +import { expect } from 'chai' +import fs from 'node:fs' +import path from 'node:path' +import { CandyMachineConfig } from '../../../src/lib/cm/types.js' + +const execAsync = promisify(exec) + +// Helper to extract candy machine ID from cm-config.json +const getCmId = (cmDir: string): string => { + const config = JSON.parse(fs.readFileSync(path.join(cmDir, 'cm-config.json'), 'utf8')) as CandyMachineConfig + if (!config.candyMachineId) throw new Error('No candy machine ID in config') + return config.candyMachineId +} + +describe('cm guard commands', () => { + before(async () => { + await runCli( + ["toolbox", 'sol', "airdrop", "100", "TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx"] + ) + await new Promise(resolve => setTimeout(resolve, 10000)) + }) + + it('can update guards on a candy machine', async () => { + const cmName = "testCmGuardUpdate" + + try { + const { collectionId } = await createCoreCollection() + + // Create CM with guards + await execAsync(`npm run create-test-cm -- --name=${cmName} --with-config --collection=${collectionId}`) + + const { code: createCode } = await runCli( + ["cm", "create", `./${cmName}`] + ) + expect(createCode).to.equal(0) + + // Modify guards in config to new values + const configPath = path.join(process.cwd(), cmName, 'cm-config.json') + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as CandyMachineConfig + config.config.guardConfig = { + solPayment: { + lamports: 500000000, + destination: '4xbJp9sjeTEhheUDg8M1nJUomZcGmFZsjt9Gg3RQZAWp' + } + } + config.config.groups = [ + { + label: 'wl', + guards: { + startDate: { + date: Math.floor(Date.now() / 1000) + 86400 + } + } + } + ] + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)) + + // Update guards + const { stdout, stderr, code } = await runCli( + ["cm", "guard", "update", `./${cmName}`] + ) + + expect(code).to.equal(0) + expect(stderr).to.include('Candy guard updated successfully') + expect(stdout).to.include('Guard update complete') + + // Fetch and verify the candy machine still has a guard + const { stdout: fetchStdout, code: fetchCode } = await runCli( + ["cm", "fetch", getCmId(`./${cmName}`)] + ) + expect(fetchCode).to.equal(0) + // Should show candy guard data, not authority-only + expect(fetchStdout).to.not.include('authority-only') + + } finally { + try { + await execAsync(`rm -rf ./${cmName}`) + } catch { /* ignore cleanup errors */ } + } + }) + + it('can remove (unwrap) candy guard from a candy machine', async () => { + const cmName = "testCmGuardRemove" + + try { + const { collectionId } = await createCoreCollection() + + // Create CM with guards + await execAsync(`npm run create-test-cm -- --name=${cmName} --with-config --collection=${collectionId}`) + + const { code: createCode } = await runCli( + ["cm", "create", `./${cmName}`] + ) + expect(createCode).to.equal(0) + + const cmId = getCmId(`./${cmName}`) + + // Remove (unwrap) the candy guard + const { stdout, stderr, code } = await runCli( + ["cm", "guard", "remove", "--address", cmId, "--force"] + ) + + expect(code).to.equal(0) + expect(stderr).to.include('Candy guard removed successfully') + expect(stdout).to.include('Candy guard removed') + + // Fetch and verify it's now authority-only + const { stdout: fetchStdout, code: fetchCode } = await runCli( + ["cm", "fetch", cmId] + ) + expect(fetchCode).to.equal(0) + expect(fetchStdout).to.include('authority-only') + + } finally { + try { + await execAsync(`rm -rf ./${cmName}`) + } catch { /* ignore cleanup errors */ } + } + }) + + it('can delete a candy guard after removing it', async () => { + const cmName = "testCmGuardDelete" + + try { + const { collectionId } = await createCoreCollection() + + // Create CM with guards + await execAsync(`npm run create-test-cm -- --name=${cmName} --with-config --collection=${collectionId}`) + + const { code: createCode, stderr: createStderr } = await runCli( + ["cm", "create", `./${cmName}`] + ) + expect(createCode).to.equal(0) + + const cmId = getCmId(`./${cmName}`) + + // Extract the candy guard address from fetch + const { stdout: fetchStdout } = await runCli( + ["cm", "fetch", cmId] + ) + // The candy guard address is shown in the fetch output as mintAuthority + const mintAuthorityMatch = fetchStdout.match(/"mintAuthority":\s*"([^"]+)"/) + expect(mintAuthorityMatch).to.not.be.null + const candyGuardAddress = mintAuthorityMatch![1] + + // Remove (unwrap) the candy guard first + const { code: removeCode } = await runCli( + ["cm", "guard", "remove", "--address", cmId, "--force"] + ) + expect(removeCode).to.equal(0) + + // Delete the candy guard + const { stdout, stderr, code } = await runCli( + ["cm", "guard", "delete", "--address", candyGuardAddress, "--force"] + ) + + expect(code).to.equal(0) + expect(stderr).to.include('Candy guard deleted successfully') + expect(stdout).to.include('Candy guard deleted') + + } finally { + try { + await execAsync(`rm -rf ./${cmName}`) + } catch { /* ignore cleanup errors */ } + } + }) + + it('fails to delete a non-existent candy guard', async () => { + const fakeAddress = '11111111111111111111111111111111' + + let failed = false + try { + await runCli( + ["cm", "guard", "delete", "--address", fakeAddress, "--force"] + ) + } catch (error) { + failed = true + expect((error as Error).message).to.include('does not exist') + } + expect(failed).to.be.true + }) + + it('fails to remove candy guard from authority-only candy machine', async () => { + const cmName = "testCmGuardRemoveNoGuard" + + try { + const { collectionId } = await createCoreCollection() + + // Create CM without guards (authority-only) + await execAsync(`npm run create-test-cm -- --name=${cmName} --with-config --collection=${collectionId}`) + + const configPath = path.join(process.cwd(), cmName, 'cm-config.json') + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as CandyMachineConfig + config.config.guardConfig = {} + config.config.groups = [] + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)) + + const { code: createCode, stderr: createStderr } = await runCli( + ["cm", "create", `./${cmName}`] + ) + expect(createCode).to.equal(0) + expect(createStderr).to.include('authority-only') + + const cmId = getCmId(`./${cmName}`) + + // Try to remove — should fail + let failed = false + try { + await runCli( + ["cm", "guard", "remove", "--address", cmId, "--force"] + ) + } catch (error) { + failed = true + expect((error as Error).message).to.include('authority-only') + } + expect(failed).to.be.true + + } finally { + try { + await execAsync(`rm -rf ./${cmName}`) + } catch { /* ignore cleanup errors */ } + } + }) +}) From c9facf42427470a03451f704887ac60cbb409a7f Mon Sep 17 00:00:00 2001 From: MarkSackerberg <93528482+MarkSackerberg@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:43:08 +0200 Subject: [PATCH 2/4] Address cm guard PR review comments - Validate candy guard address format in delete before lookup - Restructure authority-only short-circuit in remove/update so errors are not swallowed and rewrapped by the fetch try/catch - Move wizard config save after validation so aborted runs do not leave a mutated cm-config.json on disk - Replace process.exit with this.exit in the wizard abort helper - Enforce y/n/q and positive-integer validation on wizard prompts - Extract MAX_GROUP_LABEL_LENGTH constant to cm-utils - Parse candy guard address from remove stderr in tests instead of regexing cm fetch JSON output --- src/commands/cm/guard/delete.ts | 11 ++++- src/commands/cm/guard/remove.ts | 23 +++++---- src/commands/cm/guard/update.ts | 77 +++++++++++++++++++------------ src/lib/cm/cm-utils.ts | 2 + test/commands/cm/cm.guard.test.ts | 17 +++---- 5 files changed, 77 insertions(+), 53 deletions(-) diff --git a/src/commands/cm/guard/delete.ts b/src/commands/cm/guard/delete.ts index 13460d18..e11f391c 100644 --- a/src/commands/cm/guard/delete.ts +++ b/src/commands/cm/guard/delete.ts @@ -3,7 +3,7 @@ import { fetchCandyGuard, deleteCandyGuard, } from '@metaplex-foundation/mpl-core-candy-machine' -import { publicKey } from '@metaplex-foundation/umi' +import { isPublicKey, publicKey } from '@metaplex-foundation/umi' import { Flags } from '@oclif/core' import ora from 'ora' import { TransactionCommand } from '../../../TransactionCommand.js' @@ -44,6 +44,10 @@ export default class CmGuardDelete extends TransactionCommand { if (typeof val === 'string' && val.trim().toLowerCase() === 'q') { - console.log('Aborting wizard by user request.') - process.exit(0) + this.log('Aborting wizard by user request.') + this.exit(0) } if (Array.isArray(val) && val.includes('Quit')) { - console.log('Aborting wizard by user request.') - process.exit(0) + this.log('Aborting wizard by user request.') + this.exit(0) } } @@ -231,10 +234,16 @@ export default class CmGuardUpdate extends TransactionCommand guard).sort() + const yesNoQuitValidator = (value: string) => { + const normalized = value.trim().toLowerCase() + if (['y', 'n', 'q'].includes(normalized)) return true + return 'Please enter y, n, or q' + } + // Global guards const globalGuardsPrompt = await input({ message: 'Do you want to configure global guards? (y/n or q to quit)', - validate: () => true, + validate: yesNoQuitValidator, }) checkAbort(globalGuardsPrompt) @@ -262,24 +271,32 @@ export default class CmGuardUpdate extends TransactionCommand true, + validate: yesNoQuitValidator, }) checkAbort(enableGroupsPrompt) if (enableGroupsPrompt.trim().toLowerCase() === 'y') { const numGroupsPrompt = await input({ message: 'Enter the number of groups (or q to quit):', - validate: () => true, + validate: (value) => { + if (value.trim().toLowerCase() === 'q') return true + if (!/^\d+$/.test(value.trim())) return 'Enter a positive integer' + if (Number(value) < 1) return 'Enter a positive integer' + return true + }, }) checkAbort(numGroupsPrompt) const numGroups = Number(numGroupsPrompt) + if (!Number.isFinite(numGroups) || numGroups < 1) { + this.error('Invalid number of groups') + } for (let i = 0; i < numGroups; i++) { const groupName = await input({ - message: `Enter the name of group ${i + 1} (max 6 chars, or q to quit):`, + message: `Enter the name of group ${i + 1} (max ${MAX_GROUP_LABEL_LENGTH} chars, or q to quit):`, validate: (value) => { if (value === 'q') return true - if (value.length > 6) return 'Group label must be 6 characters or less' + if (value.length > MAX_GROUP_LABEL_LENGTH) return `Group label must be ${MAX_GROUP_LABEL_LENGTH} characters or less` if (!value) return 'Group name is required' return true }, diff --git a/src/lib/cm/cm-utils.ts b/src/lib/cm/cm-utils.ts index 0e22be93..d77b8e54 100644 --- a/src/lib/cm/cm-utils.ts +++ b/src/lib/cm/cm-utils.ts @@ -4,6 +4,8 @@ import path from 'node:path'; import { CandyMachineConfig, CandyMachineAssetCache, CandyMachineAssetCacheItem } from './types.js'; import validateAssetsFolder from './validateAssetsFolder.js'; +export const MAX_GROUP_LABEL_LENGTH = 6; + export const defaultConfigLineSettings: ConfigLineSettings = { prefixName: '', nameLength: 32, diff --git a/test/commands/cm/cm.guard.test.ts b/test/commands/cm/cm.guard.test.ts index 7ad6715c..a606c973 100644 --- a/test/commands/cm/cm.guard.test.ts +++ b/test/commands/cm/cm.guard.test.ts @@ -138,21 +138,16 @@ describe('cm guard commands', () => { const cmId = getCmId(`./${cmName}`) - // Extract the candy guard address from fetch - const { stdout: fetchStdout } = await runCli( - ["cm", "fetch", cmId] - ) - // The candy guard address is shown in the fetch output as mintAuthority - const mintAuthorityMatch = fetchStdout.match(/"mintAuthority":\s*"([^"]+)"/) - expect(mintAuthorityMatch).to.not.be.null - const candyGuardAddress = mintAuthorityMatch![1] - - // Remove (unwrap) the candy guard first - const { code: removeCode } = await runCli( + // Remove (unwrap) the candy guard first; stderr includes the candy guard address + const { stderr: removeStderr, code: removeCode } = await runCli( ["cm", "guard", "remove", "--address", cmId, "--force"] ) expect(removeCode).to.equal(0) + const guardMatch = removeStderr.match(/Found candy guard:\s*(\S+)/) + expect(guardMatch, 'candy guard address not found in remove output').to.not.be.null + const candyGuardAddress = guardMatch![1] + // Delete the candy guard const { stdout, stderr, code } = await runCli( ["cm", "guard", "delete", "--address", candyGuardAddress, "--force"] From 4e009ebaa4a2f4b0cb9a5d2f89ae296c71252385 Mon Sep 17 00:00:00 2001 From: MarkSackerberg <93528482+MarkSackerberg@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:51:51 +0200 Subject: [PATCH 3/4] Use uninitialized address for non-existent guard test The System Program address is an existing account, so fetchCandyGuard raises a deserialization/type-mismatch error rather than AccountNotFoundError. Generate a fresh keypair instead so the test exercises the intended "does not exist" branch. --- test/commands/cm/cm.guard.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/commands/cm/cm.guard.test.ts b/test/commands/cm/cm.guard.test.ts index a606c973..6410d79a 100644 --- a/test/commands/cm/cm.guard.test.ts +++ b/test/commands/cm/cm.guard.test.ts @@ -5,6 +5,8 @@ import { createCoreCollection } from '../core/corehelpers' import { expect } from 'chai' import fs from 'node:fs' import path from 'node:path' +import { generateSigner } from '@metaplex-foundation/umi' +import { createUmi } from '@metaplex-foundation/umi-bundle-defaults' import { CandyMachineConfig } from '../../../src/lib/cm/types.js' const execAsync = promisify(exec) @@ -165,7 +167,11 @@ describe('cm guard commands', () => { }) it('fails to delete a non-existent candy guard', async () => { - const fakeAddress = '11111111111111111111111111111111' + // Generate a fresh keypair so the address is guaranteed to be an + // uninitialized account (not the System Program, which would deserialize + // as a type mismatch rather than AccountNotFoundError). + const umi = createUmi('http://127.0.0.1:8899') + const fakeAddress = generateSigner(umi).publicKey.toString() let failed = false try { From 95c68d946d5a0fd0c5851d3572edb85605e8e9ef Mon Sep 17 00:00:00 2001 From: MarkSackerberg <93528482+MarkSackerberg@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:12:40 +0200 Subject: [PATCH 4/4] Normalize oclif-wrapped stderr in guard delete test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oclif wraps long error messages across lines with a ` › ` continuation marker, so "does not exist" in the rendered message ends up split as "does \n › not exist". Strip the continuation markers before the substring assertion. --- test/commands/cm/cm.guard.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/commands/cm/cm.guard.test.ts b/test/commands/cm/cm.guard.test.ts index 6410d79a..2aeddefd 100644 --- a/test/commands/cm/cm.guard.test.ts +++ b/test/commands/cm/cm.guard.test.ts @@ -180,7 +180,10 @@ describe('cm guard commands', () => { ) } catch (error) { failed = true - expect((error as Error).message).to.include('does not exist') + // oclif wraps long error messages with ` › ` continuation markers, + // so normalize whitespace before searching for the phrase + const normalized = (error as Error).message.replace(/\s*›\s*/g, ' ').replace(/\s+/g, ' ') + expect(normalized).to.include('does not exist') } expect(failed).to.be.true })