diff --git a/src/commands/genesis/create.ts b/src/commands/genesis/create.ts index eedade3..86438bd 100644 --- a/src/commands/genesis/create.ts +++ b/src/commands/genesis/create.ts @@ -1,22 +1,32 @@ import { - initializeV2, - findGenesisAccountV2Pda, - WRAPPED_SOL_MINT, + createAndRegisterLaunch, + GenesisApiConfig, + registerLaunch, } from '@metaplex-foundation/genesis' -import { generateSigner, publicKey } from '@metaplex-foundation/umi' +import { confirm } from '@inquirer/prompts' import { Flags } from '@oclif/core' import ora from 'ora' import { TransactionCommand } from '../../TransactionCommand.js' import { generateExplorerUrl } from '../../explorers.js' +import { + promptBucketChoice, + promptGenesisCreate, + promptLaunchPoolBucket, + promptLaunchWizard, + promptPresaleBucket, + promptRegisterLaunch, + promptUnlockedBucket, +} from '../../lib/genesis/createGenesisWizardPrompt.js' +import { buildLaunchInput } from '../../lib/genesis/launchApi.js' +import { + addLaunchPoolBucket, + addPresaleBucket, + addUnlockedBucket, + createGenesisAccount, + finalizeGenesis, +} from '../../lib/genesis/operations.js' import { txSignatureToString } from '../../lib/util.js' -import umiSendAndConfirmTransaction from '../../lib/umi/sendAndConfirm.js' - -// Funding modes for Genesis -const FUNDING_MODE = { - NewMint: 0, // Create a new mint (most common) - Transfer: 1, // Transfer existing tokens -} as const export default class GenesisCreate extends TransactionCommand { static override description = `Create a new Genesis account for a token launch (TGE). @@ -24,6 +34,9 @@ export default class GenesisCreate extends TransactionCommand { const { flags } = await this.parse(GenesisCreate) - const spinner = ora('Creating Genesis account...').start() - - try { - // Determine funding mode - const fundingMode = flags.fundingMode === 'transfer' - ? FUNDING_MODE.Transfer - : FUNDING_MODE.NewMint - - // Handle base mint - let baseMint - if (fundingMode === FUNDING_MODE.Transfer) { - if (!flags.baseMint) { - throw new Error('--baseMint is required when using fundingMode=transfer') - } - baseMint = publicKey(flags.baseMint) - } else { - // Generate a new mint signer for new-mint mode - baseMint = generateSigner(this.context.umi) - } + if (flags.wizard) { + return this.runWizard() + } - // Handle quote mint (default to Wrapped SOL) - const quoteMint = flags.quoteMint - ? publicKey(flags.quoteMint) - : WRAPPED_SOL_MINT + // Validate required flags for non-wizard mode + if (!flags.name) this.error('--name is required (or use --wizard for interactive mode)') + if (!flags.symbol) this.error('--symbol is required (or use --wizard for interactive mode)') + if (!flags.totalSupply) this.error('--totalSupply is required (or use --wizard for interactive mode)') - // Parse and validate total supply - if (!/^\d+$/.test(flags.totalSupply)) { - this.error(`Invalid totalSupply "${flags.totalSupply}". Must be a non-negative integer string (e.g., "1000000000").`) - } - const totalSupply = BigInt(flags.totalSupply) - - // Build the initialize transaction - const transaction = initializeV2(this.context.umi, { - baseMint, - quoteMint, - authority: this.context.signer, - payer: this.context.payer, - fundingMode, - totalSupplyBaseToken: totalSupply, - name: flags.name, - symbol: flags.symbol, - uri: flags.uri, - decimals: flags.decimals, - genesisIndex: flags.genesisIndex, - }) - - const result = await umiSendAndConfirmTransaction(this.context.umi, transaction) - - // Get the base mint public key (either from generated signer or from flags) - const baseMintPubkey = 'publicKey' in baseMint ? baseMint.publicKey : baseMint - - // Get the genesis account PDA - const [genesisAccountPda] = findGenesisAccountV2Pda(this.context.umi, { - baseMint: baseMintPubkey, - genesisIndex: flags.genesisIndex, - }) - - const signature = txSignatureToString(result.transaction.signature as Uint8Array) - const explorerUrl = generateExplorerUrl( - this.context.explorer, - this.context.chain, - signature, - 'transaction' + const spinner = ora('Creating Genesis account...').start() + try { + const result = await createGenesisAccount( + this.context.umi, this.context.signer, this.context.payer, + { + name: flags.name, + symbol: flags.symbol, + totalSupply: flags.totalSupply, + uri: flags.uri, + decimals: flags.decimals, + quoteMint: flags.quoteMint, + fundingMode: flags.fundingMode, + baseMint: flags.baseMint, + genesisIndex: flags.genesisIndex, + }, ) + const signature = txSignatureToString(result.signature) spinner.succeed('Genesis account created successfully!') this.log('') - this.logSuccess(`Genesis Account: ${genesisAccountPda}`) - this.log(`Base Mint: ${baseMintPubkey}`) - this.log(`Quote Mint: ${quoteMint}`) + this.logSuccess(`Genesis Account: ${result.genesisAccountPda}`) + this.log(`Base Mint: ${result.baseMintPubkey}`) + this.log(`Quote Mint: ${result.quoteMintPubkey}`) this.log(`Name: ${flags.name}`) this.log(`Symbol: ${flags.symbol}`) this.log(`Total Supply: ${flags.totalSupply}`) @@ -167,30 +147,236 @@ Funding Modes: this.log(`Funding Mode: ${flags.fundingMode}`) this.log('') this.log(`Transaction: ${signature}`) + this.log(generateExplorerUrl(this.context.explorer, this.context.chain, signature, 'transaction')) + } catch (error) { + spinner.fail('Failed to create Genesis account') + throw error + } + } + + private async runWizard(): Promise { + this.log( + `-------------------------------- + + Welcome to the Genesis Launch Wizard! + + Type 'q' at any prompt to abort. + +--------------------------------` + ) + + const useApi = await confirm({ + message: 'Register on the Metaplex platform? (Recommended - creates everything via the API and gives you a public launch page)', + default: true, + }) + + if (useApi) { + return this.runApiWizard() + } + + return this.runManualWizard() + } + + private async runApiWizard(): Promise { + this.log('\nThe API will create the genesis account, mint, launch pool, and register your launch in one step.\n') + + const wizardResult = await promptLaunchWizard() + const launchInput = buildLaunchInput( + this.context.signer.publicKey.toString(), + this.context.chain, + wizardResult, + ) + + const spinner = ora('Creating token launch via Genesis API...').start() + + try { + const apiConfig: GenesisApiConfig = { baseUrl: 'https://api.metaplex.com' } + + spinner.text = 'Building transactions via Genesis API...' + + const allowedCommitments = ['processed', 'confirmed', 'finalized'] as const + const commitment = allowedCommitments.includes(this.context.commitment as typeof allowedCommitments[number]) + ? (this.context.commitment as typeof allowedCommitments[number]) + : 'confirmed' + + const result = await createAndRegisterLaunch( + this.context.umi, apiConfig, launchInput, { commitment }, + ) + + spinner.succeed('Token launch created and registered successfully!') + this.log('') - this.log(explorerUrl) + this.logSuccess(`Genesis Account: ${result.genesisAccount}`) + this.log(`Mint Address: ${result.mintAddress}`) + this.log(`Launch ID: ${result.launch.id}`) + this.log(`Launch Link: ${result.launch.link}`) + this.log(`Token ID: ${result.token.id}`) this.log('') - this.log('Next steps:') - this.log(' 1. Add buckets to your Genesis account (launch pool, auction, presale, etc.)') - this.log(' 2. Configure your launch parameters') - this.log(' 3. Finalize the launch when ready') - - return { - genesisAccount: String(genesisAccountPda), - baseMint: String(baseMintPubkey), - quoteMint: String(quoteMint), - name: flags.name, - symbol: flags.symbol, - totalSupply: String(BigInt(flags.totalSupply)), - decimals: flags.decimals, - fundingMode: flags.fundingMode, - signature, - explorer: explorerUrl, + this.log('Transactions:') + for (const sig of result.signatures) { + const sigStr = txSignatureToString(sig) + this.log(` ${sigStr}`) + this.log(` ${generateExplorerUrl(this.context.explorer, this.context.chain, sigStr, 'transaction')}`) } + this.log('') + this.log('Your token launch is live! Share the launch link with your community.') + } catch (error) { + spinner.fail('Failed to create token launch') + if (error && typeof error === 'object' && 'responseBody' in error) { + this.logJson((error as { responseBody: unknown }).responseBody) + } + throw error + } + } + + private async runManualWizard(): Promise { + this.log('\nManual mode: you will create the genesis account on-chain, add buckets, and optionally finalize.\n') + + // Step 1: Create genesis account + const createResult = await promptGenesisCreate() + const spinner = ora('Creating Genesis account...').start() + let genesisAccountPda, baseMintPubkey, quoteMintPubkey + try { + const result = await createGenesisAccount( + this.context.umi, this.context.signer, this.context.payer, + { ...createResult, genesisIndex: 0 }, + ) + genesisAccountPda = result.genesisAccountPda + baseMintPubkey = result.baseMintPubkey + quoteMintPubkey = result.quoteMintPubkey + + spinner.succeed('Genesis account created successfully!') + this.log('') + this.logSuccess(`Genesis Account: ${genesisAccountPda}`) + this.log(`Base Mint: ${baseMintPubkey}`) + this.log(`Transaction: ${txSignatureToString(result.signature)}`) } catch (error) { spinner.fail('Failed to create Genesis account') throw error } + + // Step 2: Add buckets in a loop + const bucketCounts = { launchPool: 0, presale: 0, unlocked: 0 } + + // eslint-disable-next-line no-constant-condition + while (true) { + const choice = await promptBucketChoice() + if (choice === 'done') break + + try { + if (choice === 'launch-pool') { + const params = await promptLaunchPoolBucket(bucketCounts.launchPool) + const bucketSpinner = ora('Adding launch pool bucket...').start() + const result = await addLaunchPoolBucket( + this.context.umi, this.context.signer, this.context.payer, + genesisAccountPda, baseMintPubkey, quoteMintPubkey, params, + ) + bucketSpinner.succeed('Launch pool bucket added!') + this.log(` Bucket Address: ${result.bucketPda}`) + this.log(` Transaction: ${txSignatureToString(result.signature)}`) + bucketCounts.launchPool++ + } else if (choice === 'presale') { + const params = await promptPresaleBucket(bucketCounts.presale) + const bucketSpinner = ora('Adding presale bucket...').start() + const result = await addPresaleBucket( + this.context.umi, this.context.signer, this.context.payer, + genesisAccountPda, baseMintPubkey, quoteMintPubkey, params, + ) + bucketSpinner.succeed('Presale bucket added!') + this.log(` Bucket Address: ${result.bucketPda}`) + this.log(` Transaction: ${txSignatureToString(result.signature)}`) + bucketCounts.presale++ + } else if (choice === 'unlocked') { + const params = await promptUnlockedBucket(bucketCounts.unlocked) + const bucketSpinner = ora('Adding unlocked bucket...').start() + const result = await addUnlockedBucket( + this.context.umi, this.context.signer, this.context.payer, + genesisAccountPda, baseMintPubkey, quoteMintPubkey, params, + ) + bucketSpinner.succeed('Unlocked bucket added!') + this.log(` Bucket Address: ${result.bucketPda}`) + this.log(` Transaction: ${txSignatureToString(result.signature)}`) + bucketCounts.unlocked++ + } + } catch (error) { + this.warn(`Failed to add bucket: ${error instanceof Error ? error.message : String(error)}`) + this.log('You can try adding the bucket again.') + } + } + + const totalBuckets = bucketCounts.launchPool + bucketCounts.presale + bucketCounts.unlocked + if (totalBuckets === 0) { + this.log('\nNo buckets were added. You can add buckets later with:') + this.log(` mplx genesis bucket add-launch-pool ${genesisAccountPda} ...`) + this.log(` mplx genesis bucket add-presale ${genesisAccountPda} ...`) + this.log(` mplx genesis bucket add-unlocked ${genesisAccountPda} ...`) + return + } + + // Step 3: Finalize + const shouldFinalize = await confirm({ + message: 'Finalize the Genesis launch now? (locks configuration)', + default: false, + }) + + if (shouldFinalize) { + const finalizeSpinner = ora('Finalizing Genesis launch...').start() + try { + const sig = await finalizeGenesis(this.context.umi, this.context.signer, genesisAccountPda) + finalizeSpinner.succeed('Genesis launch finalized!') + this.log(` Transaction: ${txSignatureToString(sig)}`) + } catch (error) { + finalizeSpinner.fail('Failed to finalize Genesis launch') + throw error + } + } else { + this.log(`\nYou can finalize later with: mplx genesis finalize ${genesisAccountPda}`) + } + + // Step 4: Register on platform + const shouldRegister = await confirm({ + message: 'Register this launch on the Metaplex platform? (creates a public launch page)', + default: true, + }) + + let registered = false + if (shouldRegister) { + try { + const regResult = await promptRegisterLaunch() + const regSpinner = ora('Registering on Metaplex platform...').start() + const createLaunchInput = buildLaunchInput( + this.context.signer.publicKey.toString(), + this.context.chain, + { ...regResult, name: createResult.name, symbol: createResult.symbol }, + ) + const apiConfig: GenesisApiConfig = { baseUrl: 'https://api.metaplex.com' } + const result = await registerLaunch(this.context.umi, apiConfig, { + genesisAccount: genesisAccountPda.toString(), + createLaunchInput, + }) + if (result.existing) { + regSpinner.succeed('Launch was already registered.') + } else { + regSpinner.succeed('Launch registered on Metaplex platform!') + } + this.log(` Launch ID: ${result.launch.id}`) + this.log(` Launch Link: ${result.launch.link}`) + this.log(` Token ID: ${result.token.id}`) + registered = true + } catch (error) { + this.warn(`Failed to register: ${error instanceof Error ? error.message : String(error)}`) + this.log(`You can register later with: mplx genesis launch register ${genesisAccountPda} --launchConfig launch.json`) + } + } else { + this.log(`\nYou can register later with: mplx genesis launch register ${genesisAccountPda} --launchConfig launch.json`) + } + + // Summary + this.log('\n--- Wizard Complete ---') + this.log(`Genesis Account: ${genesisAccountPda}`) + this.log(`Buckets added: ${totalBuckets} (${bucketCounts.launchPool} launch pool, ${bucketCounts.presale} presale, ${bucketCounts.unlocked} unlocked)`) + if (shouldFinalize) this.log('Status: Finalized') + if (registered) this.log('Status: Registered on Metaplex platform') } } diff --git a/src/commands/genesis/launch/create.ts b/src/commands/genesis/launch/create.ts index 1146275..f495076 100644 --- a/src/commands/genesis/launch/create.ts +++ b/src/commands/genesis/launch/create.ts @@ -14,6 +14,7 @@ import ora from 'ora' import { TransactionCommand } from '../../../TransactionCommand.js' import { generateExplorerUrl } from '../../../explorers.js' import { readJsonSync } from '../../../lib/file.js' +import { promptLaunchWizard } from '../../../lib/genesis/createGenesisWizardPrompt.js' import { detectSvmNetwork, txSignatureToString } from '../../../lib/util.js' export default class GenesisLaunchCreate extends TransactionCommand { @@ -27,17 +28,25 @@ This is an all-in-one command that: The Genesis API handles creating the genesis account, mint, launch pool bucket, and optional locked allocations in a single flow. +Use --wizard for an interactive setup process. + Launch types: - project: Total supply 1B, 48-hour deposit period, configurable allocations. - memecoin: Total supply 1B, 1-hour deposit period, hardcoded fund flows. Only --depositStartTime is required.` static override examples = [ + '$ mplx genesis launch create --wizard', '$ mplx genesis launch create --name "My Token" --symbol "MTK" --image "https://gateway.irys.xyz/abc123" --tokenAllocation 500000000 --depositStartTime 2025-03-01T00:00:00Z --raiseGoal 200 --raydiumLiquidityBps 5000 --fundsRecipient
', '$ mplx genesis launch create --launchType memecoin --name "My Meme" --symbol "MEME" --image "https://gateway.irys.xyz/abc123" --depositStartTime 2025-03-01T00:00:00Z', '$ mplx genesis launch create --name "My Token" --symbol "MTK" --image "https://gateway.irys.xyz/abc123" --tokenAllocation 500000000 --depositStartTime 2025-03-01T00:00:00Z --raiseGoal 200 --raydiumLiquidityBps 5000 --fundsRecipient
--lockedAllocations allocations.json', ] static override flags = { + wizard: Flags.boolean({ + description: 'Use interactive wizard to create a genesis launch', + required: false, + }), + // Launch type launchType: Flags.option({ description: 'Launch type: project (default) or memecoin', @@ -49,16 +58,16 @@ Launch types: name: Flags.string({ char: 'n', description: 'Name of the token (1-32 characters)', - required: true, + required: false, }), symbol: Flags.string({ char: 's', description: 'Symbol of the token (1-10 characters)', - required: true, + required: false, }), image: Flags.string({ description: 'Token image URL (must start with https://gateway.irys.xyz/)', - required: true, + required: false, }), description: Flags.string({ description: 'Token description (max 250 characters)', @@ -80,7 +89,7 @@ Launch types: // Shared config depositStartTime: Flags.string({ description: 'Deposit start time (ISO date string or unix timestamp). Project: 48h deposit. Memecoin: 1h deposit.', - required: true, + required: false, }), // Project-only launchpool config @@ -127,6 +136,16 @@ Launch types: public async run(): Promise { const { flags } = await this.parse(GenesisLaunchCreate) + if (flags.wizard) { + return this.runWizard(flags) + } + + // Validate required flags for non-wizard mode + if (!flags.name) this.error('--name is required (or use --wizard for interactive mode)') + if (!flags.symbol) this.error('--symbol is required (or use --wizard for interactive mode)') + if (!flags.image) this.error('--image is required (or use --wizard for interactive mode)') + if (!flags.depositStartTime) this.error('--depositStartTime is required (or use --wizard for interactive mode)') + const isMemecoin = flags.launchType === 'memecoin' // Reject project-only flags for memecoin launches @@ -154,29 +173,106 @@ Launch types: } } + await this.executeLaunch({ + launchType: flags.launchType, + name: flags.name, + symbol: flags.symbol, + image: flags.image, + description: flags.description, + website: flags.website, + twitter: flags.twitter, + telegram: flags.telegram, + depositStartTime: flags.depositStartTime, + quoteMint: flags.quoteMint, + tokenAllocation: flags.tokenAllocation, + raiseGoal: flags.raiseGoal, + raydiumLiquidityBps: flags.raydiumLiquidityBps, + fundsRecipient: flags.fundsRecipient, + lockedAllocationsFile: flags.lockedAllocations, + network: flags.network, + apiUrl: flags.apiUrl, + }) + } + + private async runWizard(flags: { network?: 'solana-mainnet' | 'solana-devnet'; apiUrl: string }): Promise { + this.log( + `-------------------------------- + + Welcome to the Genesis Launch Wizard! + + This wizard will guide you through creating a new token launch + via the Genesis API (all-in-one flow). + + Type 'q' at any prompt to abort. + +--------------------------------` + ) + + const result = await promptLaunchWizard() + + await this.executeLaunch({ + launchType: result.launchType, + name: result.name, + symbol: result.symbol, + image: result.image, + description: result.description, + website: result.website, + twitter: result.twitter, + telegram: result.telegram, + depositStartTime: result.depositStartTime, + quoteMint: result.quoteMint, + tokenAllocation: result.tokenAllocation, + raiseGoal: result.raiseGoal, + raydiumLiquidityBps: result.raydiumLiquidityBps, + fundsRecipient: result.fundsRecipient, + network: flags.network, + apiUrl: flags.apiUrl, + }) + } + + private async executeLaunch(params: { + launchType: 'project' | 'memecoin' + name: string + symbol: string + image: string + description?: string + website?: string + twitter?: string + telegram?: string + depositStartTime: string + quoteMint: string + tokenAllocation?: number + raiseGoal?: number + raydiumLiquidityBps?: number + fundsRecipient?: string + lockedAllocationsFile?: string + network?: 'solana-mainnet' | 'solana-devnet' + apiUrl: string + }): Promise { + const isMemecoin = params.launchType === 'memecoin' const spinner = ora('Creating token launch via Genesis API...').start() try { // Detect network from chain if not specified - const network: SvmNetwork = flags.network ?? detectSvmNetwork(this.context.chain) + const network: SvmNetwork = params.network ?? detectSvmNetwork(this.context.chain) // Build external links const externalLinks: Record = {} - if (flags.website) externalLinks.website = flags.website - if (flags.twitter) externalLinks.twitter = flags.twitter - if (flags.telegram) externalLinks.telegram = flags.telegram + if (params.website) externalLinks.website = params.website + if (params.twitter) externalLinks.twitter = params.twitter + if (params.telegram) externalLinks.telegram = params.telegram // Build token metadata const wallet = this.context.signer.publicKey.toString() const token = { - name: flags.name, - symbol: flags.symbol, - image: flags.image, - ...(flags.description && { description: flags.description }), + name: params.name, + symbol: params.symbol, + image: params.image, + ...(params.description && { description: params.description }), ...(Object.keys(externalLinks).length > 0 && { externalLinks }), } - let input: CreateLaunchInput + let launchInput: CreateLaunchInput if (isMemecoin) { const memecoinInput: CreateMemecoinLaunchInput = { @@ -184,17 +280,17 @@ Launch types: token, launchType: 'memecoin', launch: { - depositStartTime: flags.depositStartTime, + depositStartTime: params.depositStartTime, }, network, - ...(flags.quoteMint !== 'SOL' && { quoteMint: flags.quoteMint as QuoteMintInput }), + ...(params.quoteMint !== 'SOL' && { quoteMint: params.quoteMint as QuoteMintInput }), } - input = memecoinInput + launchInput = memecoinInput } else { // Parse locked allocations from JSON file if provided let lockedAllocations: LockedAllocation[] | undefined - if (flags.lockedAllocations) { - lockedAllocations = this.parseLockedAllocations(flags.lockedAllocations) + if (params.lockedAllocationsFile) { + lockedAllocations = this.parseLockedAllocations(params.lockedAllocationsFile) } const projectInput: CreateProjectLaunchInput = { @@ -203,22 +299,22 @@ Launch types: launchType: 'project', launch: { launchpool: { - tokenAllocation: flags.tokenAllocation!, - depositStartTime: flags.depositStartTime, - raiseGoal: flags.raiseGoal!, - raydiumLiquidityBps: flags.raydiumLiquidityBps!, - fundsRecipient: flags.fundsRecipient!, + tokenAllocation: params.tokenAllocation!, + depositStartTime: params.depositStartTime, + raiseGoal: params.raiseGoal!, + raydiumLiquidityBps: params.raydiumLiquidityBps!, + fundsRecipient: params.fundsRecipient!, }, ...(lockedAllocations && { lockedAllocations }), }, network, - ...(flags.quoteMint !== 'SOL' && { quoteMint: flags.quoteMint as QuoteMintInput }), + ...(params.quoteMint !== 'SOL' && { quoteMint: params.quoteMint as QuoteMintInput }), } - input = projectInput + launchInput = projectInput } const apiConfig: GenesisApiConfig = { - baseUrl: flags.apiUrl, + baseUrl: params.apiUrl, } spinner.text = 'Building transactions via Genesis API...' @@ -231,7 +327,7 @@ Launch types: const result = await createAndRegisterLaunch( this.context.umi, apiConfig, - input, + launchInput, { commitment }, ) diff --git a/src/lib/genesis/createGenesisWizardPrompt.ts b/src/lib/genesis/createGenesisWizardPrompt.ts new file mode 100644 index 0000000..c71eb46 --- /dev/null +++ b/src/lib/genesis/createGenesisWizardPrompt.ts @@ -0,0 +1,936 @@ +import { confirm, input, select } from '@inquirer/prompts' +import { isPublicKey } from '@metaplex-foundation/umi' + +export function checkAbort(val: unknown) { + if (typeof val === 'string' && val.trim().toLowerCase() === 'q') { + console.log('Aborting wizard.') + process.exit(0) + } +} + +function parseTimestamp(v: string, { requireFuture = false }: { requireFuture?: boolean } = {}): string | true { + if (!v.trim()) return 'Required' + const asNum = Number(v) + if (!isNaN(asNum)) { + if (asNum < 1_000_000_000) return 'Invalid unix timestamp. Must be a full unix timestamp (e.g. 1717200000) or ISO date (e.g. 2025-06-01T00:00:00Z)' + if (requireFuture && asNum <= Math.floor(Date.now() / 1000)) return 'Timestamp must be in the future' + return true + } + const d = new Date(v) + if (isNaN(d.getTime())) return 'Invalid date. Use ISO format (e.g. 2025-06-01T00:00:00Z) or unix timestamp' + if (requireFuture && d.getTime() <= Date.now()) return 'Date must be in the future' + return true +} + +const MIN_RAISE_GOAL: Record = { SOL: 250, USDC: 25000 } + +function validateUrl(v: string, type: 'website' | 'twitter' | 'telegram'): string | true { + let url: URL + try { + url = new URL(v.trim()) + } catch { + return 'Invalid URL' + } + if (!['http:', 'https:'].includes(url.protocol)) return 'URL must start with http:// or https://' + if (type === 'twitter') { + if (!['twitter.com', 'www.twitter.com', 'x.com', 'www.x.com'].includes(url.hostname)) { + return 'Twitter URL must be from twitter.com or x.com' + } + } + if (type === 'telegram') { + if (!['t.me', 'telegram.me'].includes(url.hostname)) { + return 'Telegram URL must be from t.me or telegram.me' + } + } + return true +} + +// --- Genesis Account Creation --- + +export interface GenesisCreateResult { + name: string + symbol: string + totalSupply: string + uri: string + decimals: number + quoteMint?: string + fundingMode: 'new-mint' | 'transfer' + baseMint?: string +} + +export async function promptGenesisCreate(): Promise { + console.log('\n--- Token Configuration ---') + + const name = await input({ + message: 'Token name (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!v.trim()) return 'Name is required' + return true + }, + }) + checkAbort(name) + + const symbol = await input({ + message: 'Token symbol (e.g. MTK) (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!v.trim()) return 'Symbol is required' + return true + }, + }) + checkAbort(symbol) + + const totalSupply = await input({ + message: 'Total supply in base units (e.g. 1000000000 for 1B with 9 decimals) (or q to quit):', + default: '1000000000', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!/^\d+$/.test(v)) return 'Must be a non-negative integer' + if (BigInt(v) <= 0n) return 'Must be greater than 0' + return true + }, + }) + checkAbort(totalSupply) + + const decimalsStr = await input({ + message: 'Token decimals (or q to quit):', + default: '9', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + const n = Number(v) + if (isNaN(n) || !Number.isInteger(n) || n < 0 || n > 18) return 'Must be an integer between 0 and 18' + return true + }, + }) + checkAbort(decimalsStr) + + const uri = await input({ + message: 'Metadata URI (or press enter to skip, q to quit):', + }) + checkAbort(uri) + + const fundingMode = await select({ + message: 'Funding mode:', + choices: [ + { name: 'New Mint - Create a new token (most common)', value: 'new-mint' as const }, + { name: 'Transfer - Use an existing mint', value: 'transfer' as const }, + ], + }) + + let baseMint: string | undefined + if (fundingMode === 'transfer') { + const mint = await input({ + message: 'Existing base mint address (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!isPublicKey(v)) return 'Invalid Solana public key' + return true + }, + }) + checkAbort(mint) + baseMint = mint + } + + const useCustomQuote = await confirm({ + message: 'Use a custom quote mint? (default: Wrapped SOL)', + default: false, + }) + + let quoteMint: string | undefined + if (useCustomQuote) { + const mint = await input({ + message: 'Quote mint address (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!isPublicKey(v)) return 'Invalid Solana public key' + return true + }, + }) + checkAbort(mint) + quoteMint = mint + } + + return { + name, + symbol, + totalSupply, + uri: uri.trim() || '', + decimals: Number(decimalsStr), + fundingMode, + baseMint, + quoteMint, + } +} + +// --- Launch Pool Bucket --- + +export interface LaunchPoolBucketResult { + allocation: string + depositStart: string + depositEnd: string + claimStart: string + claimEnd: string + bucketIndex: number + // Optional extensions + minimumDeposit?: string + depositLimit?: string + minimumQuoteTokenThreshold?: string +} + +export async function promptLaunchPoolBucket(nextIndex: number): Promise { + console.log('\n--- Launch Pool Bucket ---') + console.log('Pro-rata allocation: everyone gets the same price, allocation based on contribution.') + + const allocation = await input({ + message: 'Token allocation for this bucket (in base units) (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!/^\d+$/.test(v) || BigInt(v) <= 0n) return 'Must be a positive integer' + return true + }, + }) + checkAbort(allocation) + + const bucketIndexStr = await input({ + message: 'Bucket index (or q to quit):', + default: String(nextIndex), + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + const n = Number(v) + if (isNaN(n) || !Number.isInteger(n) || n < 0) return 'Must be a non-negative integer' + return true + }, + }) + checkAbort(bucketIndexStr) + + console.log('\nSchedule (ISO dates or unix timestamps):') + + const depositStart = await input({ + message: 'Deposit start time (or q to quit):', + validate: (v) => { checkAbort(v); return parseTimestamp(v) }, + }) + + const depositEnd = await input({ + message: 'Deposit end time (or q to quit):', + validate: (v) => { checkAbort(v); return parseTimestamp(v) }, + }) + + const claimStart = await input({ + message: 'Claim start time (or q to quit):', + validate: (v) => { checkAbort(v); return parseTimestamp(v) }, + }) + + const claimEnd = await input({ + message: 'Claim end time (or q to quit):', + validate: (v) => { checkAbort(v); return parseTimestamp(v) }, + }) + + // Optional extensions + const addExtensions = await confirm({ + message: 'Configure optional extensions (deposit limits, minimum deposit, etc.)?', + default: false, + }) + + let minimumDeposit: string | undefined + let depositLimit: string | undefined + let minimumQuoteTokenThreshold: string | undefined + + if (addExtensions) { + const minDep = await input({ + message: 'Minimum deposit per transaction (base units, or press enter to skip, q to quit):', + }) + checkAbort(minDep) + if (minDep.trim()) minimumDeposit = minDep.trim() + + const depLimit = await input({ + message: 'Max deposit limit per user (base units, or press enter to skip, q to quit):', + }) + checkAbort(depLimit) + if (depLimit.trim()) depositLimit = depLimit.trim() + + const minThreshold = await input({ + message: 'Minimum total quote tokens required for bucket to succeed (or press enter to skip, q to quit):', + }) + checkAbort(minThreshold) + if (minThreshold.trim()) minimumQuoteTokenThreshold = minThreshold.trim() + } + + return { + allocation, + depositStart: toUnixTimestamp(depositStart), + depositEnd: toUnixTimestamp(depositEnd), + claimStart: toUnixTimestamp(claimStart), + claimEnd: toUnixTimestamp(claimEnd), + bucketIndex: Number(bucketIndexStr), + minimumDeposit, + depositLimit, + minimumQuoteTokenThreshold, + } +} + +// --- Presale Bucket --- + +export interface PresaleBucketResult { + allocation: string + quoteCap: string + depositStart: string + depositEnd: string + claimStart: string + claimEnd?: string + bucketIndex: number + minimumDeposit?: string + depositLimit?: string +} + +export async function promptPresaleBucket(nextIndex: number): Promise { + console.log('\n--- Presale Bucket ---') + console.log('Fixed-price allocation: price = quoteCap / allocation.') + + const allocation = await input({ + message: 'Token allocation (in base units) (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!/^\d+$/.test(v) || BigInt(v) <= 0n) return 'Must be a positive integer' + return true + }, + }) + checkAbort(allocation) + + const quoteCap = await input({ + message: 'Quote token cap (total quote tokens accepted) (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!/^\d+$/.test(v) || BigInt(v) <= 0n) return 'Must be a positive integer' + return true + }, + }) + checkAbort(quoteCap) + + const bucketIndexStr = await input({ + message: 'Bucket index (or q to quit):', + default: String(nextIndex), + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + const n = Number(v) + if (isNaN(n) || !Number.isInteger(n) || n < 0) return 'Must be a non-negative integer' + return true + }, + }) + checkAbort(bucketIndexStr) + + console.log('\nSchedule (ISO dates or unix timestamps):') + + const depositStart = await input({ + message: 'Deposit start time (or q to quit):', + validate: (v) => { checkAbort(v); return parseTimestamp(v) }, + }) + + const depositEnd = await input({ + message: 'Deposit end time (or q to quit):', + validate: (v) => { checkAbort(v); return parseTimestamp(v) }, + }) + + const claimStart = await input({ + message: 'Claim start time (or q to quit):', + validate: (v) => { checkAbort(v); return parseTimestamp(v) }, + }) + + const hasClaimEnd = await confirm({ + message: 'Set a claim end time? (default: far future)', + default: false, + }) + + let claimEnd: string | undefined + if (hasClaimEnd) { + const ce = await input({ + message: 'Claim end time (or q to quit):', + validate: (v) => { checkAbort(v); return parseTimestamp(v) }, + }) + claimEnd = toUnixTimestamp(ce) + } + + // Optional limits + const addLimits = await confirm({ + message: 'Configure deposit limits?', + default: false, + }) + + let minimumDeposit: string | undefined + let depositLimit: string | undefined + + if (addLimits) { + const minDep = await input({ + message: 'Minimum deposit per transaction (base units, or press enter to skip, q to quit):', + }) + checkAbort(minDep) + if (minDep.trim()) minimumDeposit = minDep.trim() + + const depLimit = await input({ + message: 'Max deposit limit per user (base units, or press enter to skip, q to quit):', + }) + checkAbort(depLimit) + if (depLimit.trim()) depositLimit = depLimit.trim() + } + + return { + allocation, + quoteCap, + depositStart: toUnixTimestamp(depositStart), + depositEnd: toUnixTimestamp(depositEnd), + claimStart: toUnixTimestamp(claimStart), + claimEnd, + bucketIndex: Number(bucketIndexStr), + minimumDeposit, + depositLimit, + } +} + +// --- Unlocked Bucket --- + +export interface UnlockedBucketResult { + recipient: string + allocation: string + claimStart: string + claimEnd?: string + bucketIndex: number +} + +export async function promptUnlockedBucket(nextIndex: number): Promise { + console.log('\n--- Unlocked Bucket ---') + console.log('Treasury/team allocation: tokens claimable by a designated recipient. No deposits.') + + const recipient = await input({ + message: 'Recipient wallet address (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!isPublicKey(v)) return 'Invalid Solana public key' + return true + }, + }) + checkAbort(recipient) + + const allocation = await input({ + message: 'Token allocation (in base units, or 0) (or q to quit):', + default: '0', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!/^\d+$/.test(v)) return 'Must be a non-negative integer' + return true + }, + }) + checkAbort(allocation) + + const bucketIndexStr = await input({ + message: 'Bucket index (or q to quit):', + default: String(nextIndex), + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + const n = Number(v) + if (isNaN(n) || !Number.isInteger(n) || n < 0) return 'Must be a non-negative integer' + return true + }, + }) + checkAbort(bucketIndexStr) + + console.log('\nSchedule (ISO dates or unix timestamps):') + + const claimStart = await input({ + message: 'Claim start time (or q to quit):', + validate: (v) => { checkAbort(v); return parseTimestamp(v) }, + }) + + const hasClaimEnd = await confirm({ + message: 'Set a claim end time? (default: far future)', + default: false, + }) + + let claimEnd: string | undefined + if (hasClaimEnd) { + const ce = await input({ + message: 'Claim end time (or q to quit):', + validate: (v) => { checkAbort(v); return parseTimestamp(v) }, + }) + claimEnd = toUnixTimestamp(ce) + } + + return { + recipient, + allocation, + claimStart: toUnixTimestamp(claimStart), + claimEnd, + bucketIndex: Number(bucketIndexStr), + } +} + +// --- Launch API Wizard (project/memecoin) --- + +export interface LaunchWizardResult { + launchType: 'project' | 'memecoin' + name: string + symbol: string + image: string + description?: string + website?: string + twitter?: string + telegram?: string + depositStartTime: string + quoteMint: 'SOL' | 'USDC' + // Project-only + tokenAllocation?: number + raiseGoal?: number + raydiumLiquidityBps?: number + fundsRecipient?: string +} + +export async function promptLaunchWizard(): Promise { + // Launch type + const launchType = await select({ + message: 'What type of launch?', + choices: [ + { name: 'Project - Configurable allocations, 48h deposit, locked vesting', value: 'project' as const }, + { name: 'Memecoin - Simple launch, 1h deposit, hardcoded fund flows', value: 'memecoin' as const }, + ], + }) + + // Token metadata + console.log('\n--- Token Metadata ---') + + const name = await input({ + message: 'Token name (1-32 characters) (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!v.trim()) return 'Name is required' + if (v.length > 32) return 'Max 32 characters' + return true + }, + }) + checkAbort(name) + + const symbol = await input({ + message: 'Token symbol (1-10 characters) (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!v.trim()) return 'Symbol is required' + if (v.length > 10) return 'Max 10 characters' + return true + }, + }) + checkAbort(symbol) + + const image = await input({ + message: 'Token image URL (must start with https://gateway.irys.xyz/) (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!v.startsWith('https://gateway.irys.xyz/')) return 'Must start with https://gateway.irys.xyz/' + return true + }, + }) + checkAbort(image) + + const addDescription = await confirm({ + message: 'Add a token description?', + default: false, + }) + let description: string | undefined + if (addDescription) { + const desc = await input({ + message: 'Description (max 250 characters) (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (v.length > 250) return 'Max 250 characters' + return true + }, + }) + checkAbort(desc) + description = desc + } + + // Social links + const addSocials = await confirm({ + message: 'Add social links (website, twitter, telegram)?', + default: false, + }) + let website: string | undefined + let twitter: string | undefined + let telegram: string | undefined + if (addSocials) { + const w = await input({ + message: 'Website URL (or press enter to skip, q to quit):', + validate: (v) => { + if (!v.trim()) return true + checkAbort(v) + return validateUrl(v, 'website') + }, + }) + if (w.trim()) website = w.trim() + + const t = await input({ + message: 'Twitter URL (or press enter to skip, q to quit):', + validate: (v) => { + if (!v.trim()) return true + checkAbort(v) + return validateUrl(v, 'twitter') + }, + }) + if (t.trim()) twitter = t.trim() + + const tg = await input({ + message: 'Telegram URL (or press enter to skip, q to quit):', + validate: (v) => { + if (!v.trim()) return true + checkAbort(v) + return validateUrl(v, 'telegram') + }, + }) + if (tg.trim()) telegram = tg.trim() + } + + // Quote mint + const quoteMint = await select({ + message: 'Quote token (what depositors pay with):', + choices: [ + { name: 'SOL', value: 'SOL' as const }, + { name: 'USDC', value: 'USDC' as const }, + ], + }) + + // Deposit start time + console.log('\n--- Launch Schedule ---') + const depositPeriod = launchType === 'memecoin' ? '1 hour' : '48 hours' + const depositStartTime = await input({ + message: `Deposit start time (ISO date or unix timestamp, deposit lasts ${depositPeriod}) (or q to quit):`, + validate: (v) => { checkAbort(v); return parseTimestamp(v, { requireFuture: true }) }, + }) + + // Project-only options + let tokenAllocation: number | undefined + let raiseGoal: number | undefined + let raydiumLiquidityBps: number | undefined + let fundsRecipient: string | undefined + + if (launchType === 'project') { + console.log('\n--- Launch Pool Configuration ---') + console.log('Total token supply is 1,000,000,000 (1 billion).') + + const tokenAllocationStr = await input({ + message: 'Token allocation for launch pool (portion of 1B, e.g. 500000000) (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + const n = Number(v) + if (isNaN(n) || n <= 0 || !Number.isInteger(n)) return 'Must be a positive integer' + if (n > 1_000_000_000) return 'Cannot exceed 1 billion total supply' + return true + }, + }) + checkAbort(tokenAllocationStr) + tokenAllocation = Number(tokenAllocationStr) + + const quoteLabel = quoteMint === 'SOL' ? 'SOL' : 'USDC' + const raiseGoalStr = await input({ + message: `Raise goal in whole ${quoteLabel} (minimum ${MIN_RAISE_GOAL[quoteLabel]} ${quoteLabel}) (or q to quit):`, + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + const n = Number(v) + if (isNaN(n) || n <= 0) return 'Must be a positive number' + const min = MIN_RAISE_GOAL[quoteLabel] ?? 0 + if (n < min) return `Raise goal must be at least ${min} ${quoteLabel}` + return true + }, + }) + checkAbort(raiseGoalStr) + raiseGoal = Number(raiseGoalStr) + + const raydiumBpsStr = await input({ + message: 'Raydium liquidity percentage (20-100, e.g. 50 for 50%) (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + const n = Number(v) + if (isNaN(n) || n < 20 || n > 100) return 'Must be between 20 and 100' + return true + }, + }) + checkAbort(raydiumBpsStr) + raydiumLiquidityBps = Number(raydiumBpsStr) * 100 + + const remainderPct = 100 - Number(raydiumBpsStr) + if (remainderPct > 0) { + console.log(` ${Number(raydiumBpsStr)}% goes to Raydium LP, ${remainderPct}% goes to funds recipient.`) + } + + const fundsRecipientAddr = await input({ + message: 'Funds recipient wallet address (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!isPublicKey(v)) return 'Invalid Solana public key' + return true + }, + }) + checkAbort(fundsRecipientAddr) + fundsRecipient = fundsRecipientAddr + } + + // Confirmation summary + console.log('\n--- Launch Summary ---') + console.log(` Type: ${launchType}`) + console.log(` Token: ${name} (${symbol})`) + console.log(` Image: ${image}`) + if (description) console.log(` Description: ${description}`) + if (website) console.log(` Website: ${website}`) + if (twitter) console.log(` Twitter: ${twitter}`) + if (telegram) console.log(` Telegram: ${telegram}`) + console.log(` Quote Mint: ${quoteMint}`) + console.log(` Deposit Start: ${depositStartTime}`) + console.log(` Deposit Duration: ${depositPeriod}`) + if (launchType === 'project') { + console.log(` Token Allocation: ${tokenAllocation!.toLocaleString()}`) + console.log(` Raise Goal: ${raiseGoal} ${quoteMint}`) + console.log(` Raydium Liquidity: ${raydiumLiquidityBps! / 100}%`) + console.log(` Funds Recipient: ${fundsRecipient}`) + } + console.log('') + + const proceed = await confirm({ + message: 'Proceed with creating this launch?', + default: true, + }) + + if (!proceed) { + console.log('Aborting wizard.') + process.exit(0) + } + + return { + launchType, + name, + symbol, + image, + description, + website, + twitter, + telegram, + depositStartTime, + quoteMint, + ...(launchType === 'project' && { + tokenAllocation, + raiseGoal, + raydiumLiquidityBps, + fundsRecipient, + }), + } +} + +// --- Bucket type selector --- + +export type BucketChoice = 'launch-pool' | 'presale' | 'unlocked' | 'done' + +export async function promptBucketChoice(): Promise { + return select({ + message: 'Add a bucket to your Genesis account:', + choices: [ + { name: 'Launch Pool - Pro-rata allocation, everyone gets same price', value: 'launch-pool' as const }, + { name: 'Presale - Fixed-price token allocation', value: 'presale' as const }, + { name: 'Unlocked - Treasury/team allocation (no deposits)', value: 'unlocked' as const }, + { name: 'Done - Finish adding buckets', value: 'done' as const }, + ], + }) +} + +// --- Platform Registration Prompt (for genesis create wizard) --- + +export interface RegisterLaunchResult { + launchType: 'project' | 'memecoin' + image: string + description?: string + website?: string + twitter?: string + telegram?: string + depositStartTime: string + quoteMint: 'SOL' | 'USDC' + // Project-only + tokenAllocation?: number + raiseGoal?: number + raydiumLiquidityBps?: number + fundsRecipient?: string +} + +export async function promptRegisterLaunch(): Promise { + console.log('\n--- Platform Registration ---') + console.log('Register your launch on the Metaplex platform to get a public launch page.') + + const launchType = await select({ + message: 'Launch type:', + choices: [ + { name: 'Project - Configurable allocations, 48h deposit', value: 'project' as const }, + { name: 'Memecoin - Simple launch, 1h deposit', value: 'memecoin' as const }, + ], + }) + + const image = await input({ + message: 'Token image URL (must start with https://gateway.irys.xyz/) (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!v.startsWith('https://gateway.irys.xyz/')) return 'Must start with https://gateway.irys.xyz/' + return true + }, + }) + checkAbort(image) + + const addDescription = await confirm({ + message: 'Add a token description?', + default: false, + }) + let description: string | undefined + if (addDescription) { + const desc = await input({ + message: 'Description (max 250 characters) (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (v.length > 250) return 'Max 250 characters' + return true + }, + }) + checkAbort(desc) + description = desc + } + + const addSocials = await confirm({ + message: 'Add social links (website, twitter, telegram)?', + default: false, + }) + let website: string | undefined + let twitter: string | undefined + let telegram: string | undefined + if (addSocials) { + const w = await input({ + message: 'Website URL (or press enter to skip, q to quit):', + validate: (v) => { + if (!v.trim()) return true + checkAbort(v) + return validateUrl(v, 'website') + }, + }) + if (w.trim()) website = w.trim() + + const t = await input({ + message: 'Twitter URL (or press enter to skip, q to quit):', + validate: (v) => { + if (!v.trim()) return true + checkAbort(v) + return validateUrl(v, 'twitter') + }, + }) + if (t.trim()) twitter = t.trim() + + const tg = await input({ + message: 'Telegram URL (or press enter to skip, q to quit):', + validate: (v) => { + if (!v.trim()) return true + checkAbort(v) + return validateUrl(v, 'telegram') + }, + }) + if (tg.trim()) telegram = tg.trim() + } + + const quoteMint = await select({ + message: 'Quote token:', + choices: [ + { name: 'SOL', value: 'SOL' as const }, + { name: 'USDC', value: 'USDC' as const }, + ], + }) + + const depositPeriod = launchType === 'memecoin' ? '1 hour' : '48 hours' + const depositStartTime = await input({ + message: `Deposit start time (ISO date or unix timestamp, deposit lasts ${depositPeriod}) (or q to quit):`, + validate: (v) => { checkAbort(v); return parseTimestamp(v, { requireFuture: true }) }, + }) + + let tokenAllocation: number | undefined + let raiseGoal: number | undefined + let raydiumLiquidityBps: number | undefined + let fundsRecipient: string | undefined + + if (launchType === 'project') { + console.log('\n--- Launch Pool Configuration ---') + + const tokenAllocationStr = await input({ + message: 'Token allocation for launch pool (portion of 1B) (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + const n = Number(v) + if (isNaN(n) || n <= 0 || !Number.isInteger(n)) return 'Must be a positive integer' + if (n > 1_000_000_000) return 'Cannot exceed 1 billion total supply' + return true + }, + }) + checkAbort(tokenAllocationStr) + tokenAllocation = Number(tokenAllocationStr) + + const quoteLabel = quoteMint === 'SOL' ? 'SOL' : 'USDC' + const raiseGoalStr = await input({ + message: `Raise goal in whole ${quoteLabel} (minimum ${MIN_RAISE_GOAL[quoteLabel]} ${quoteLabel}) (or q to quit):`, + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + const n = Number(v) + if (isNaN(n) || n <= 0) return 'Must be a positive number' + const min = MIN_RAISE_GOAL[quoteLabel] ?? 0 + if (n < min) return `Raise goal must be at least ${min} ${quoteLabel}` + return true + }, + }) + checkAbort(raiseGoalStr) + raiseGoal = Number(raiseGoalStr) + + const raydiumBpsStr = await input({ + message: 'Raydium liquidity percentage (20-100) (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + const n = Number(v) + if (isNaN(n) || n < 20 || n > 100) return 'Must be between 20 and 100' + return true + }, + }) + checkAbort(raydiumBpsStr) + raydiumLiquidityBps = Number(raydiumBpsStr) * 100 + + const fundsRecipientAddr = await input({ + message: 'Funds recipient wallet address (or q to quit):', + validate: (v) => { + if (v.trim().toLowerCase() === 'q') return true + if (!isPublicKey(v)) return 'Invalid Solana public key' + return true + }, + }) + checkAbort(fundsRecipientAddr) + fundsRecipient = fundsRecipientAddr + } + + return { + launchType, + image, + description, + website, + twitter, + telegram, + depositStartTime, + quoteMint, + ...(launchType === 'project' && { + tokenAllocation, + raiseGoal, + raydiumLiquidityBps, + fundsRecipient, + }), + } +} + +// --- Helpers --- + +function toUnixTimestamp(v: string): string { + const asNum = Number(v) + if (!isNaN(asNum) && asNum >= 1_000_000_000) return v + const d = new Date(v) + if (isNaN(d.getTime())) throw new Error(`Invalid date: ${v}`) + return String(Math.floor(d.getTime() / 1000)) +} diff --git a/src/lib/genesis/launchApi.ts b/src/lib/genesis/launchApi.ts new file mode 100644 index 0000000..6934a2b --- /dev/null +++ b/src/lib/genesis/launchApi.ts @@ -0,0 +1,74 @@ +import { + CreateLaunchInput, + CreateMemecoinLaunchInput, + CreateProjectLaunchInput, + QuoteMintInput, + SvmNetwork, +} from '@metaplex-foundation/genesis' +import { detectSvmNetwork, RpcChain } from '../util.js' + +export interface BuildLaunchInputParams { + launchType: 'project' | 'memecoin' + name: string + symbol: string + image: string + description?: string + website?: string + twitter?: string + telegram?: string + depositStartTime: string + quoteMint: 'SOL' | 'USDC' + tokenAllocation?: number + raiseGoal?: number + raydiumLiquidityBps?: number + fundsRecipient?: string +} + +export function buildLaunchInput( + wallet: string, + chain: RpcChain, + params: BuildLaunchInputParams, +): CreateLaunchInput { + const network: SvmNetwork = detectSvmNetwork(chain) + + const externalLinks: Record = {} + if (params.website) externalLinks.website = params.website + if (params.twitter) externalLinks.twitter = params.twitter + if (params.telegram) externalLinks.telegram = params.telegram + + const token = { + name: params.name, + symbol: params.symbol, + image: params.image, + ...(params.description && { description: params.description }), + ...(Object.keys(externalLinks).length > 0 && { externalLinks }), + } + + if (params.launchType === 'memecoin') { + return { + wallet, + token, + launchType: 'memecoin', + launch: { depositStartTime: params.depositStartTime }, + network, + ...(params.quoteMint !== 'SOL' && { quoteMint: params.quoteMint as QuoteMintInput }), + } satisfies CreateMemecoinLaunchInput + } + + return { + wallet, + token, + launchType: 'project', + launch: { + launchpool: { + tokenAllocation: params.tokenAllocation!, + depositStartTime: params.depositStartTime, + raiseGoal: params.raiseGoal!, + raydiumLiquidityBps: params.raydiumLiquidityBps!, + fundsRecipient: params.fundsRecipient!, + }, + }, + network, + ...(params.quoteMint !== 'SOL' && { quoteMint: params.quoteMint as QuoteMintInput }), + } satisfies CreateProjectLaunchInput +} diff --git a/src/lib/genesis/operations.ts b/src/lib/genesis/operations.ts new file mode 100644 index 0000000..a6a6c03 --- /dev/null +++ b/src/lib/genesis/operations.ts @@ -0,0 +1,360 @@ +import { + addLaunchPoolBucketV2Base, + addLaunchPoolBucketV2Extensions, + addPresaleBucketV2, + addUnlockedBucketV2, + createTimeAbsoluteCondition, + finalizeV2, + findGenesisAccountV2Pda, + findLaunchPoolBucketV2Pda, + findPresaleBucketV2Pda, + findUnlockedBucketV2Pda, + initializeV2, + LaunchPoolV2ExtensionArgs, + safeFetchGenesisAccountV2, + WRAPPED_SOL_MINT, +} from '@metaplex-foundation/genesis' +import { AccountMeta, generateSigner, publicKey, PublicKey, Signer, Umi } from '@metaplex-foundation/umi' +import { none, some } from '@metaplex-foundation/umi' +import umiSendAndConfirmTransaction from '../umi/sendAndConfirm.js' + +const FUNDING_MODE = { + NewMint: 0, + Transfer: 1, +} as const + +export interface CreateGenesisAccountParams { + name: string + symbol: string + totalSupply: string + uri: string + decimals: number + quoteMint?: string + fundingMode: 'new-mint' | 'transfer' + baseMint?: string + genesisIndex: number +} + +export interface CreateGenesisAccountResult { + genesisAccountPda: PublicKey + baseMintPubkey: PublicKey + quoteMintPubkey: PublicKey + signature: Uint8Array +} + +export async function createGenesisAccount( + umi: Umi, + signer: Signer, + payer: Signer | undefined, + params: CreateGenesisAccountParams, +): Promise { + const fundingMode = params.fundingMode === 'transfer' + ? FUNDING_MODE.Transfer + : FUNDING_MODE.NewMint + + let baseMint + if (fundingMode === FUNDING_MODE.Transfer) { + if (!params.baseMint) { + throw new Error('Base mint is required when using transfer funding mode') + } + baseMint = publicKey(params.baseMint) + } else { + baseMint = generateSigner(umi) + } + + const quoteMint = params.quoteMint + ? publicKey(params.quoteMint) + : WRAPPED_SOL_MINT + + if (!/^\d+$/.test(params.totalSupply)) { + throw new Error(`Invalid totalSupply "${params.totalSupply}". Must be a non-negative integer string.`) + } + const totalSupply = BigInt(params.totalSupply) + + const transaction = initializeV2(umi, { + baseMint, + quoteMint, + authority: signer, + payer, + fundingMode, + totalSupplyBaseToken: totalSupply, + name: params.name, + symbol: params.symbol, + uri: params.uri, + decimals: params.decimals, + genesisIndex: params.genesisIndex, + }) + + const result = await umiSendAndConfirmTransaction(umi, transaction) + + const baseMintPubkey = 'publicKey' in baseMint ? baseMint.publicKey : baseMint + + const [genesisAccountPda] = findGenesisAccountV2Pda(umi, { + baseMint: baseMintPubkey, + genesisIndex: params.genesisIndex, + }) + + return { + genesisAccountPda, + baseMintPubkey, + quoteMintPubkey: quoteMint, + signature: result.transaction.signature as Uint8Array, + } +} + +export interface AddLaunchPoolBucketParams { + allocation: string + depositStart: string + depositEnd: string + claimStart: string + claimEnd: string + bucketIndex: number + minimumDeposit?: string + depositLimit?: string + minimumQuoteTokenThreshold?: string +} + +export interface AddBucketResult { + bucketPda: PublicKey + signature: Uint8Array +} + +export async function addLaunchPoolBucket( + umi: Umi, + signer: Signer, + payer: Signer | undefined, + genesisAccount: PublicKey, + baseMint: PublicKey, + quoteMint: PublicKey, + params: AddLaunchPoolBucketParams, +): Promise { + const [bucketPda] = findLaunchPoolBucketV2Pda(umi, { + genesisAccount, + bucketIndex: params.bucketIndex, + }) + + const baseTx = addLaunchPoolBucketV2Base(umi, { + genesisAccount, + baseMint, + quoteMint, + authority: signer, + payer, + bucketIndex: params.bucketIndex, + baseTokenAllocation: BigInt(params.allocation), + depositStartCondition: createTimeAbsoluteCondition(BigInt(params.depositStart)), + depositEndCondition: createTimeAbsoluteCondition(BigInt(params.depositEnd)), + claimStartCondition: createTimeAbsoluteCondition(BigInt(params.claimStart)), + claimEndCondition: createTimeAbsoluteCondition(BigInt(params.claimEnd)), + }) + + const result = await umiSendAndConfirmTransaction(umi, baseTx) + + // Add extensions if any + const extensions: LaunchPoolV2ExtensionArgs[] = [] + if (params.depositLimit) { + extensions.push({ __kind: 'DepositLimit', depositLimit: { limit: BigInt(params.depositLimit) } }) + } + if (params.minimumDeposit) { + extensions.push({ __kind: 'MinimumDepositAmount', minimumDepositAmount: { amount: BigInt(params.minimumDeposit) } }) + } + if (params.minimumQuoteTokenThreshold) { + extensions.push({ __kind: 'MinimumQuoteTokenThreshold', minimumQuoteTokenThreshold: { amount: BigInt(params.minimumQuoteTokenThreshold) } }) + } + + if (extensions.length > 0) { + const extensionsTx = addLaunchPoolBucketV2Extensions(umi, { + authority: signer, + bucket: bucketPda, + extensions, + genesisAccount, + padding: Array.from({ length: 3 }, () => 0), + payer, + }) + await umiSendAndConfirmTransaction(umi, extensionsTx) + } + + return { bucketPda, signature: result.transaction.signature as Uint8Array } +} + +export interface AddPresaleBucketParams { + allocation: string + quoteCap: string + depositStart: string + depositEnd: string + claimStart: string + claimEnd?: string + bucketIndex: number + minimumDeposit?: string + depositLimit?: string +} + +export async function addPresaleBucket( + umi: Umi, + signer: Signer, + payer: Signer | undefined, + genesisAccount: PublicKey, + baseMint: PublicKey, + quoteMint: PublicKey, + params: AddPresaleBucketParams, +): Promise { + const claimEnd = params.claimEnd ? BigInt(params.claimEnd) : BigInt('4102444800') + const conditionPadding = new Array(47).fill(0) + + const transaction = addPresaleBucketV2(umi, { + genesisAccount, + baseMint, + quoteMint, + authority: signer, + payer, + bucketIndex: params.bucketIndex, + baseTokenAllocation: BigInt(params.allocation), + allocationQuoteTokenCap: BigInt(params.quoteCap), + depositStartCondition: { + __kind: 'TimeAbsolute' as const, + padding: conditionPadding, + time: BigInt(params.depositStart), + triggeredTimestamp: BigInt(0), + }, + depositEndCondition: { + __kind: 'TimeAbsolute' as const, + padding: conditionPadding, + time: BigInt(params.depositEnd), + triggeredTimestamp: BigInt(0), + }, + claimStartCondition: { + __kind: 'TimeAbsolute' as const, + padding: conditionPadding, + time: BigInt(params.claimStart), + triggeredTimestamp: BigInt(0), + }, + claimEndCondition: { + __kind: 'TimeAbsolute' as const, + padding: conditionPadding, + time: claimEnd, + triggeredTimestamp: BigInt(0), + }, + backendSigner: none(), + depositLimit: params.depositLimit + ? some({ limit: BigInt(params.depositLimit) }) + : none(), + allowlist: none(), + claimSchedule: none(), + minimumDepositAmount: params.minimumDeposit + ? some({ amount: BigInt(params.minimumDeposit) }) + : none(), + endBehaviors: [], + depositCooldown: none(), + perCooldownDepositLimit: none(), + steppedDepositLimit: none(), + }) + + const result = await umiSendAndConfirmTransaction(umi, transaction) + + const [bucketPda] = findPresaleBucketV2Pda(umi, { + genesisAccount, + bucketIndex: params.bucketIndex, + }) + + return { bucketPda, signature: result.transaction.signature as Uint8Array } +} + +export interface AddUnlockedBucketParams { + recipient: string + allocation: string + claimStart: string + claimEnd?: string + bucketIndex: number +} + +export async function addUnlockedBucket( + umi: Umi, + signer: Signer, + payer: Signer | undefined, + genesisAccount: PublicKey, + baseMint: PublicKey, + quoteMint: PublicKey, + params: AddUnlockedBucketParams, +): Promise { + const claimEnd = params.claimEnd ? BigInt(params.claimEnd) : BigInt('4102444800') + const conditionPadding = new Array(47).fill(0) + + const transaction = addUnlockedBucketV2(umi, { + genesisAccount, + baseMint, + quoteMint, + authority: signer, + payer, + recipient: publicKey(params.recipient), + bucketIndex: params.bucketIndex, + baseTokenAllocation: BigInt(params.allocation), + claimStartCondition: { + __kind: 'TimeAbsolute' as const, + padding: conditionPadding, + time: BigInt(params.claimStart), + triggeredTimestamp: BigInt(0), + }, + claimEndCondition: { + __kind: 'TimeAbsolute' as const, + padding: conditionPadding, + time: claimEnd, + triggeredTimestamp: BigInt(0), + }, + backendSigner: none(), + }) + + const result = await umiSendAndConfirmTransaction(umi, transaction) + + const [bucketPda] = findUnlockedBucketV2Pda(umi, { + genesisAccount, + bucketIndex: params.bucketIndex, + }) + + return { bucketPda, signature: result.transaction.signature as Uint8Array } +} + +export async function finalizeGenesis( + umi: Umi, + signer: Signer, + genesisAccount: PublicKey, +): Promise { + const account = await safeFetchGenesisAccountV2(umi, genesisAccount) + if (!account) { + throw new Error('Genesis account not found') + } + + const pdaFinders = [ + findLaunchPoolBucketV2Pda, + findPresaleBucketV2Pda, + findUnlockedBucketV2Pda, + ] + + const pdaLookups: { pda: PublicKey }[] = [] + for (let i = 0; i < account.bucketCount; i++) { + for (const finder of pdaFinders) { + const [pda] = finder(umi, { genesisAccount, bucketIndex: i }) + pdaLookups.push({ pda }) + } + } + + const accounts = await Promise.all( + pdaLookups.map(({ pda }) => umi.rpc.getAccount(pda)) + ) + + const bucketAccounts: AccountMeta[] = accounts + .map((acct, idx) => ({ account: acct, pda: pdaLookups[idx].pda })) + .filter(({ account: acct }) => acct.exists) + .map(({ pda }) => ({ + pubkey: pda, + isSigner: false, + isWritable: true, + })) + + const transaction = finalizeV2(umi, { + genesisAccount, + baseMint: account.baseMint, + authority: signer, + }).addRemainingAccounts(bucketAccounts) + + const result = await umiSendAndConfirmTransaction(umi, transaction) + return result.transaction.signature as Uint8Array +} diff --git a/test/commands/genesis/genesis.create.test.ts b/test/commands/genesis/genesis.create.test.ts index dfba8aa..067c02a 100644 --- a/test/commands/genesis/genesis.create.test.ts +++ b/test/commands/genesis/genesis.create.test.ts @@ -133,7 +133,7 @@ describe('genesis create and fetch commands', () => { await runCli(cliInput) expect.fail('Should have thrown an error for missing required flags') } catch (error) { - expect((error as Error).message).to.contain('Missing required flag') + expect((error as Error).message).to.contain('is required') } }) @@ -171,7 +171,7 @@ describe('genesis create and fetch commands', () => { await runCli(cliInput) expect.fail('Should have thrown an error for missing baseMint') } catch (error) { - expect((error as Error).message).to.contain('baseMint is required') + expect((error as Error).message).to.contain('mint is required') } }) }) diff --git a/test/commands/genesis/genesis.launch.test.ts b/test/commands/genesis/genesis.launch.test.ts index 5d3cb5d..63c261e 100644 --- a/test/commands/genesis/genesis.launch.test.ts +++ b/test/commands/genesis/genesis.launch.test.ts @@ -26,11 +26,8 @@ describe('genesis launch commands', () => { expect.fail('Should have thrown an error for missing required flags') } catch (error) { const msg = (error as Error).message - expect(msg).to.contain('Missing required flag') - expect(msg).to.contain('name') - expect(msg).to.contain('symbol') - expect(msg).to.contain('image') - expect(msg).to.contain('depositStartTime') + // With wizard support, flags are validated manually and the first missing flag triggers an error + expect(msg).to.contain('is required') } }) @@ -53,8 +50,9 @@ describe('genesis launch commands', () => { await runCli(cliInput) expect.fail('Should have thrown an error for missing required flag') } catch (error) { - expect((error as Error).message).to.contain('Missing required flag') - expect((error as Error).message).to.contain(omitted) + const msg = (error as Error).message + expect(msg).to.contain('is required') + expect(msg).to.contain(omitted) } }) } diff --git a/test/commands/genesis/genesis.wizard.test.ts b/test/commands/genesis/genesis.wizard.test.ts new file mode 100644 index 0000000..18f37eb --- /dev/null +++ b/test/commands/genesis/genesis.wizard.test.ts @@ -0,0 +1,578 @@ +import { expect } from 'chai' +import { spawn, ChildProcess } from 'child_process' +import { join } from 'path' +import { runCli } from '../../runCli' + +const CLI_PATH = join(process.cwd(), 'bin', 'run.js') +const WALLET = '11111111111111111111111111111111' + +function futureIso(offsetSeconds: number): string { + return new Date(Date.now() + offsetSeconds * 1000).toISOString() +} + +const FUTURE_ISO = futureIso(7 * 86400) + +const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07|\x1b\[\?[0-9;]*[a-zA-Z]/g +function stripAnsi(s: string): string { + return s.replace(ANSI_RE, '') +} + +/** + * Lightweight pexpect-like helper using `script` to get a real PTY. + * @inquirer/prompts requires a TTY to render interactive UI. + */ +class PtyProcess { + private child: ChildProcess + private output = '' + private closed = false + + constructor(args: string[]) { + // Use `script` to allocate a real PTY on Linux + // script -qfc "command" /dev/null + const cmd = `node ${CLI_PATH} ${args.join(' ')} -r http://127.0.0.1:8899 -k test-files/key.json` + this.child = spawn('script', ['-qfc', cmd, '/dev/null'], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, TERM: 'dumb' }, + }) + + this.child.stdout?.on('data', (data) => { + this.output += data.toString() + }) + + this.child.stderr?.on('data', (data) => { + this.output += data.toString() + }) + + this.child.on('close', () => { + this.closed = true + }) + } + + /** Wait until output contains the expected string, or timeout. */ + async expect(text: string, timeoutMs = 15000): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const clean = stripAnsi(this.output) + if (clean.includes(text)) { + return clean + } + await sleep(100) + } + const clean = stripAnsi(this.output) + throw new Error(`Timeout waiting for "${text}".\nOutput so far:\n${clean.slice(-1000)}`) + } + + /** Type text and press Enter. */ + sendLine(text: string): void { + this.child.stdin?.write(text + '\n') + } + + /** Press Enter (confirm default). */ + pressEnter(): void { + this.child.stdin?.write('\n') + } + + /** Send down arrow key. */ + pressDown(): void { + this.child.stdin?.write('\x1b[B') + } + + /** Select an option in a list by pressing down N times then Enter. */ + async selectOption(index: number): Promise { + for (let i = 0; i < index; i++) { + this.pressDown() + await sleep(150) + } + await sleep(100) + this.pressEnter() + } + + /** Confirm with 'n' + Enter. */ + confirmNo(): void { + this.child.stdin?.write('n\n') + } + + + /** Wait for the process to exit. */ + async waitForExit(timeoutMs = 30000): Promise { + const start = Date.now() + while (!this.closed && Date.now() - start < timeoutMs) { + await sleep(100) + } + } + + /** Get all output so far. */ + getOutput(): string { + return stripAnsi(this.output) + } + + /** Kill the process. */ + kill(): void { + if (!this.closed) { + this.child.kill('SIGTERM') + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function spawnWizard(command: string[]): PtyProcess { + return new PtyProcess(command) +} + +describe('genesis wizard (interactive)', function () { + this.timeout(120000) // Wizards can be slow + + before(async () => { + await runCli(['toolbox', 'sol', 'airdrop', '100', 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx']) + await new Promise(resolve => setTimeout(resolve, 5000)) + }) + + describe('genesis create --wizard (abort)', () => { + it('aborts cleanly when user types q', async () => { + const pty = spawnWizard(['genesis', 'create', '--wizard']) + + try { + await pty.expect('Welcome to the Genesis Launch Wizard') + + // "Register on Metaplex platform?" -> No + await pty.expect('Register on the Metaplex platform') + pty.confirmNo() + + await pty.expect('Manual mode') + + // Abort at token name + await pty.expect('Token name') + pty.sendLine('q') + + await pty.waitForExit() + const output = pty.getOutput() + expect(output).to.contain('Aborting wizard') + } finally { + pty.kill() + } + }) + }) + + describe('genesis create --wizard (manual flow)', () => { + it('creates genesis account and adds a launch pool bucket', async () => { + const pty = spawnWizard(['genesis', 'create', '--wizard']) + + try { + await pty.expect('Welcome to the Genesis Launch Wizard') + + // "Register on Metaplex platform?" -> No + await pty.expect('Register on the Metaplex platform') + pty.confirmNo() + + await pty.expect('Manual mode') + + // --- Token Configuration --- + await pty.expect('Token name') + pty.sendLine('WizardTestToken') + + await pty.expect('Token symbol') + pty.sendLine('WTT') + + await pty.expect('Total supply') + pty.sendLine('1000000000') + + await pty.expect('Token decimals') + pty.sendLine('9') + + await pty.expect('Metadata URI') + pty.sendLine('') // skip + + // Funding mode -> New Mint (first option) + await pty.expect('Funding mode') + await pty.selectOption(0) + + // "Use custom quote mint?" -> No + await pty.expect('custom quote mint') + await sleep(200) + pty.confirmNo() + + // Wait for genesis creation + await pty.expect('Genesis account created successfully', 60000) + + // --- Add Launch Pool bucket (first option) --- + await pty.expect('Add a bucket') + await pty.selectOption(0) + + await pty.expect('Token allocation for this bucket') + pty.sendLine('500000000') + + await pty.expect('Bucket index') + pty.sendLine('0') + + // Schedule — use past/future timestamps + const now = Math.floor(Date.now() / 1000) + + await pty.expect('Deposit start time') + pty.sendLine((now - 3600).toString()) + + await pty.expect('Deposit end time') + pty.sendLine((now + 86400).toString()) + + await pty.expect('Claim start time') + pty.sendLine((now + 86400 + 1).toString()) + + await pty.expect('Claim end time') + pty.sendLine((now + 86400 * 365).toString()) + + // "Configure optional extensions?" -> No + await pty.expect('Configure optional extensions') + pty.confirmNo() + + await pty.expect('Launch pool bucket added', 60000) + + // --- Done adding buckets --- + await pty.expect('Add a bucket') + await pty.selectOption(3) // Done + + // "Finalize?" -> No + await pty.expect('Finalize') + pty.confirmNo() + + // "Register on platform?" -> No + await pty.expect('Register this launch') + pty.confirmNo() + + await pty.expect('Wizard Complete') + await pty.waitForExit() + + const output = pty.getOutput() + expect(output).to.contain('Wizard Complete') + expect(output).to.contain('1 launch pool') + } finally { + pty.kill() + } + }) + }) + + describe('genesis create --wizard (API flow)', () => { + it('completes project launch wizard and reaches API call', async () => { + const pty = spawnWizard(['genesis', 'create', '--wizard']) + + try { + await pty.expect('Welcome to the Genesis Launch Wizard') + + // "Register on Metaplex platform?" -> Yes (default) + await pty.expect('Register on the Metaplex platform') + pty.pressEnter() + + await pty.expect('API will create') + + // "What type of launch?" -> project (first option) + await pty.expect('What type of launch') + await pty.selectOption(0) + + // --- Token Metadata --- + await pty.expect('Token name') + pty.sendLine('ApiWizProject') + + await pty.expect('Token symbol') + pty.sendLine('AWP') + + await pty.expect('Token image URL') + pty.sendLine('https://gateway.irys.xyz/test-img') + + // "Add description?" -> No + await pty.expect('Add a token description') + pty.confirmNo() + + // "Add social links?" -> No + await pty.expect('Add social links') + pty.confirmNo() + + // Quote token -> SOL (first option) + await pty.expect('Quote token') + await pty.selectOption(0) + + // Deposit start time + await pty.expect('Deposit start time') + pty.sendLine(FUTURE_ISO) + + // --- Launch Pool Configuration --- + await pty.expect('Token allocation for launch pool') + pty.sendLine('500000000') + + await pty.expect('Raise goal') + pty.sendLine('250') + + await pty.expect('Raydium liquidity percentage') + pty.sendLine('50') + + await pty.expect('Funds recipient wallet') + pty.sendLine(WALLET) + + // Summary + await pty.expect('Launch Summary') + await pty.expect('ApiWizProject') + + // "Proceed?" -> Yes + await pty.expect('Proceed') + pty.pressEnter() + + // Wait for API call — will fail on localnet, that's expected + await pty.waitForExit(90000) + + const output = pty.getOutput() + // The wizard flow completed — it either succeeded or hit an API error + expect( + output.includes('created and registered successfully') || output.includes('Failed to create token launch') + ).to.equal(true, 'Expected either success or API error') + } finally { + pty.kill() + } + }) + + it('completes memecoin launch wizard and reaches API call', async () => { + const pty = spawnWizard(['genesis', 'create', '--wizard']) + + try { + await pty.expect('Welcome to the Genesis Launch Wizard') + + // "Register on Metaplex platform?" -> Yes + await pty.expect('Register on the Metaplex platform') + pty.pressEnter() + + await pty.expect('API will create') + + // "What type of launch?" -> memecoin (second option) + await pty.expect('What type of launch') + await pty.selectOption(1) + + // Token metadata + await pty.expect('Token name') + pty.sendLine('ApiWizMeme') + + await pty.expect('Token symbol') + pty.sendLine('AWM') + + await pty.expect('Token image URL') + pty.sendLine('https://gateway.irys.xyz/test-meme') + + // No description, no socials + await pty.expect('Add a token description') + pty.confirmNo() + + await pty.expect('Add social links') + pty.confirmNo() + + // Quote token -> SOL + await pty.expect('Quote token') + await pty.selectOption(0) + + // Deposit start + await pty.expect('Deposit start time') + pty.sendLine(FUTURE_ISO) + + // Summary + await pty.expect('Launch Summary') + await pty.expect('memecoin') + + // Proceed + await pty.expect('Proceed') + pty.pressEnter() + + await pty.waitForExit(90000) + + const output = pty.getOutput() + expect( + output.includes('created and registered successfully') || output.includes('Failed to create token launch') + ).to.equal(true, 'Expected either success or API error') + } finally { + pty.kill() + } + }) + }) + + describe('genesis launch create --wizard', () => { + it('completes project launch wizard via launch create command', async () => { + const pty = spawnWizard(['genesis', 'launch', 'create', '--wizard']) + + try { + await pty.expect('Welcome to the Genesis Launch Wizard') + + // "What type of launch?" -> project + await pty.expect('What type of launch') + await pty.selectOption(0) + + await pty.expect('Token name') + pty.sendLine('LaunchWizProject') + + await pty.expect('Token symbol') + pty.sendLine('LWP') + + await pty.expect('Token image URL') + pty.sendLine('https://gateway.irys.xyz/launch-proj') + + await pty.expect('Add a token description') + pty.confirmNo() + + await pty.expect('Add social links') + pty.confirmNo() + + await pty.expect('Quote token') + await pty.selectOption(0) + + await pty.expect('Deposit start time') + pty.sendLine(FUTURE_ISO) + + await pty.expect('Token allocation for launch pool') + pty.sendLine('500000000') + + await pty.expect('Raise goal') + pty.sendLine('250') + + await pty.expect('Raydium liquidity percentage') + pty.sendLine('50') + + await pty.expect('Funds recipient wallet') + pty.sendLine(WALLET) + + await pty.expect('Launch Summary') + + await pty.expect('Proceed') + pty.pressEnter() + + await pty.waitForExit(90000) + + const output = pty.getOutput() + expect( + output.includes('created and registered successfully') || output.includes('Failed to create token launch') + ).to.equal(true) + } finally { + pty.kill() + } + }) + }) + + describe('wizard input validation', () => { + it('rejects invalid deposit start time (e.g. "20")', async () => { + const pty = spawnWizard(['genesis', 'launch', 'create', '--wizard']) + + try { + await pty.expect('Welcome to the Genesis Launch Wizard') + + // project + await pty.expect('What type of launch') + await pty.selectOption(0) + + await pty.expect('Token name') + pty.sendLine('ValidTest') + + await pty.expect('Token symbol') + pty.sendLine('VT') + + await pty.expect('Token image URL') + pty.sendLine('https://gateway.irys.xyz/valid') + + await pty.expect('Add a token description') + pty.confirmNo() + + await pty.expect('Add social links') + pty.confirmNo() + + await pty.expect('Quote token') + await pty.selectOption(0) + + // Enter invalid timestamp + await pty.expect('Deposit start time') + pty.sendLine('20') + + // Should show validation error + await pty.expect('Invalid unix timestamp') + } finally { + pty.kill() + } + }) + + it('rejects invalid Twitter URL', async () => { + const pty = spawnWizard(['genesis', 'launch', 'create', '--wizard']) + + try { + await pty.expect('Welcome to the Genesis Launch Wizard') + + await pty.expect('What type of launch') + await pty.selectOption(0) + + await pty.expect('Token name') + pty.sendLine('UrlTest') + + await pty.expect('Token symbol') + pty.sendLine('UT') + + await pty.expect('Token image URL') + pty.sendLine('https://gateway.irys.xyz/url-test') + + await pty.expect('Add a token description') + pty.confirmNo() + + // Yes to social links (default is false, so type 'y') + await pty.expect('Add social links') + pty.sendLine('y') + + // Website — skip + await pty.expect('Website URL') + pty.sendLine('') + + // Twitter — enter invalid URL + await pty.expect('Twitter URL') + pty.sendLine('https://example.com/nottwitter') + + // Should show validation error + await pty.expect('Twitter URL must be from twitter.com or x.com') + } finally { + pty.kill() + } + }) + + it('rejects raise goal below minimum', async () => { + const pty = spawnWizard(['genesis', 'launch', 'create', '--wizard']) + + try { + await pty.expect('Welcome to the Genesis Launch Wizard') + + await pty.expect('What type of launch') + await pty.selectOption(0) + + await pty.expect('Token name') + pty.sendLine('RaiseTest') + + await pty.expect('Token symbol') + pty.sendLine('RT') + + await pty.expect('Token image URL') + pty.sendLine('https://gateway.irys.xyz/raise') + + await pty.expect('Add a token description') + pty.confirmNo() + + await pty.expect('Add social links') + pty.confirmNo() + + await pty.expect('Quote token') + await pty.selectOption(0) + + await pty.expect('Deposit start time') + pty.sendLine(FUTURE_ISO) + + await pty.expect('Token allocation for launch pool') + pty.sendLine('500000000') + + // Enter below-minimum raise goal + await pty.expect('Raise goal') + pty.sendLine('1') + + // Should show error + await pty.expect('Raise goal must be at least 250 SOL') + } finally { + pty.kill() + } + }) + }) +})