diff --git a/src/commands/cm/guard/delete.ts b/src/commands/cm/guard/delete.ts new file mode 100644 index 00000000..e11f391c --- /dev/null +++ b/src/commands/cm/guard/delete.ts @@ -0,0 +1,102 @@ +import { input } from '@inquirer/prompts' +import { + fetchCandyGuard, + deleteCandyGuard, +} from '@metaplex-foundation/mpl-core-candy-machine' +import { isPublicKey, 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 + + if (!isPublicKey(candyGuardAddress)) { + this.error(`Invalid address format: ${candyGuardAddress}`) + } + + // 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') + if (error instanceof Error && error.name === 'AccountNotFoundError') { + this.error(`The account at ${candyGuardAddress} does not exist or is not a valid candy guard.`) + } + this.error(`Failed to fetch candy guard: ${error instanceof Error ? error.message : String(error)}`) + } + + // 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..9ab69947 --- /dev/null +++ b/src/commands/cm/guard/remove.ts @@ -0,0 +1,133 @@ +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 + let candyMachine + + try { + candyMachine = await fetchCandyMachine(umi, publicKey(candyMachineAddress)) + } catch (error) { + fetchSpinner.fail('Failed to fetch candy machine') + this.error(`Failed: ${error instanceof Error ? error.message : String(error)}`) + } + + if (candyMachine.mintAuthority === candyMachine.authority) { + fetchSpinner.fail('Candy machine uses authority-only minting') + this.error('This candy machine uses authority-only minting and has no candy guard attached. Nothing to remove.') + } + + candyGuardAddress = candyMachine.mintAuthority + + try { + // Verify it's actually a candy guard + await fetchCandyGuard(umi, publicKey(candyGuardAddress)) + fetchSpinner.succeed(`Found candy guard: ${candyGuardAddress}`) + } catch (error) { + fetchSpinner.fail('Failed to fetch 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..18f8af7e --- /dev/null +++ b/src/commands/cm/guard/update.ts @@ -0,0 +1,346 @@ +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 { MAX_GROUP_LABEL_LENGTH, 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 + } 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.') + } + + // Save wizard result to config now that validation has passed + if (flags.wizard && cmConfig) { + cmConfig.config.guardConfig = guardConfig + cmConfig.config.groups = groups + writeCmConfig(cmConfig, directory) + this.log('Updated cm-config.json with new guard configuration') + } + + // 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 + let candyMachine + + try { + candyMachine = await fetchCandyMachine(umi, publicKey(candyMachineAddress)) + } catch (error) { + fetchSpinner.fail('Failed to fetch candy machine') + this.error(`Failed: ${error instanceof Error ? error.message : String(error)}`) + } + + if (candyMachine.mintAuthority === candyMachine.authority) { + fetchSpinner.fail('Candy machine uses authority-only minting (no candy guard)') + this.error('This candy machine uses authority-only minting and has no 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 + + try { + // Verify it's actually a candy guard + await fetchCandyGuard(umi, publicKey(candyGuardAddress)) + fetchSpinner.succeed(`Found candy guard: ${candyGuardAddress}`) + } catch (error) { + fetchSpinner.fail('Failed to fetch 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. + +--------------------------------` + ) + + const checkAbort = (val: unknown) => { + if (typeof val === 'string' && val.trim().toLowerCase() === 'q') { + this.log('Aborting wizard by user request.') + this.exit(0) + } + if (Array.isArray(val) && val.includes('Quit')) { + this.log('Aborting wizard by user request.') + this.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() + + 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: yesNoQuitValidator, + }) + 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: yesNoQuitValidator, + }) + checkAbort(enableGroupsPrompt) + + if (enableGroupsPrompt.trim().toLowerCase() === 'y') { + const numGroupsPrompt = await input({ + message: 'Enter the number of groups (or q to quit):', + 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 ${MAX_GROUP_LABEL_LENGTH} chars, or q to quit):`, + validate: (value) => { + if (value === 'q') return true + 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 + }, + }) + 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/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 new file mode 100644 index 00000000..2aeddefd --- /dev/null +++ b/test/commands/cm/cm.guard.test.ts @@ -0,0 +1,232 @@ +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 { 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) + +// 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}`) + + // 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"] + ) + + 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 () => { + // 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 { + await runCli( + ["cm", "guard", "delete", "--address", fakeAddress, "--force"] + ) + } catch (error) { + failed = true + // 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 + }) + + 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 */ } + } + }) +})