diff --git a/.amman/genesis.so b/.amman/genesis.so index 61f9816..b7e718d 100644 Binary files a/.amman/genesis.so and b/.amman/genesis.so differ diff --git a/.github/workflows/onRelease.yml b/.github/workflows/onRelease.yml index 8dc42e6..f0a36e2 100644 --- a/.github/workflows/onRelease.yml +++ b/.github/workflows/onRelease.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: latest + node-version: 24 registry-url: https://registry.npmjs.org - name: Install npm >= 11.5.1 run: npm install -g "npm@>=11.5.1" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af79974..7f99e35 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: ['ubuntu-latest'] - node_version: [lts/-1, lts/*, latest] + node_version: [lts/-1, lts/*, 24] fail-fast: false runs-on: ${{ matrix.os }} steps: diff --git a/package.json b/package.json index 803e6af..30b5d8d 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@ledgerhq/hw-transport": "^6.31.4", "@ledgerhq/hw-transport-node-hid-singleton": "^6.31.5", "@metaplex-foundation/digital-asset-standard-api": "^2.0.0", - "@metaplex-foundation/genesis": "^0.18.0", + "@metaplex-foundation/genesis": "^0.20.5", "@metaplex-foundation/mpl-bubblegum": "^5.0.2", "@metaplex-foundation/mpl-core": "^1.4.0", "@metaplex-foundation/mpl-core-candy-machine": "^0.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a316d21..49d1ff9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^2.0.0 version: 2.0.0(@metaplex-foundation/umi@1.4.1) '@metaplex-foundation/genesis': - specifier: ^0.18.0 - version: 0.18.0(@metaplex-foundation/umi@1.4.1) + specifier: ^0.20.5 + version: 0.20.5(@metaplex-foundation/umi@1.4.1) '@metaplex-foundation/mpl-bubblegum': specifier: ^5.0.2 version: 5.0.2(@metaplex-foundation/umi@1.4.1) @@ -842,8 +842,8 @@ packages: peerDependencies: '@metaplex-foundation/umi': '>= 0.8.2 <= 1' - '@metaplex-foundation/genesis@0.18.0': - resolution: {integrity: sha512-Uzj9PYiW+6JkAGu1Kjhu5jm8p0+dSSApyIEbtfhu4FgIA9S/apx9rbt2mNL+0MgIghbQTj2zh0ooFxx6YmP6mQ==} + '@metaplex-foundation/genesis@0.20.5': + resolution: {integrity: sha512-yZZ8MEGKDJTg1hFCqkxUlsbcR+ydsGBZDmUxllSb8ePktHoQN8QxcB1/y3mBwGszhr1VABCIzVNk9IRm3UE5Bg==} peerDependencies: '@metaplex-foundation/umi': ^1.4.1 @@ -6590,7 +6590,7 @@ snapshots: dependencies: '@metaplex-foundation/umi': 1.4.1 - '@metaplex-foundation/genesis@0.18.0(@metaplex-foundation/umi@1.4.1)': + '@metaplex-foundation/genesis@0.20.5(@metaplex-foundation/umi@1.4.1)': dependencies: '@metaplex-foundation/mpl-token-metadata': 3.4.0(@metaplex-foundation/umi@1.4.1) '@metaplex-foundation/mpl-toolbox': 0.10.0(@metaplex-foundation/umi@1.4.1) diff --git a/src/commands/genesis/bucket/add-launch-pool.ts b/src/commands/genesis/bucket/add-launch-pool.ts index c6726ea..a56aba4 100644 --- a/src/commands/genesis/bucket/add-launch-pool.ts +++ b/src/commands/genesis/bucket/add-launch-pool.ts @@ -1,7 +1,9 @@ import { addLaunchPoolBucketV2, + setLaunchPoolBucketV2Behaviors, safeFetchGenesisAccountV2, findLaunchPoolBucketV2Pda, + createClaimSchedule, } from '@metaplex-foundation/genesis' import { publicKey, some, none } from '@metaplex-foundation/umi' import { Args, Flags } from '@oclif/core' @@ -196,14 +198,13 @@ Use Unix timestamps for absolute times.` // Parse optional ClaimSchedule const parseClaimSchedule = (json: string) => { const parsed = JSON.parse(json) - return { + return createClaimSchedule({ startTime: BigInt(parsed.startTime), endTime: BigInt(parsed.endTime), period: BigInt(parsed.period), cliffTime: BigInt(parsed.cliffTime), cliffAmountBps: Number(parsed.cliffAmountBps), - reserved: new Array(7).fill(0), - } + }) } // Parse optional Allowlist @@ -219,7 +220,7 @@ Use Unix timestamps for absolute times.` } } - // Build the add bucket transaction + // Build the add bucket transaction (without endBehaviors to stay within tx size limit) spinner.text = 'Adding launch pool bucket...' const transaction = addLaunchPoolBucketV2(this.context.umi, { genesisAccount: genesisAddress, @@ -258,18 +259,48 @@ Use Unix timestamps for absolute times.` minimumQuoteTokenThreshold: flags.minimumQuoteTokenThreshold ? some({ amount: BigInt(flags.minimumQuoteTokenThreshold) }) : none(), - endBehaviors, + endBehaviors: [], }) - const result = await umiSendAndConfirmTransaction(this.context.umi, transaction) - - // Get the bucket PDA + // Compute bucket PDA once for reuse const [bucketPda] = findLaunchPoolBucketV2Pda(this.context.umi, { genesisAccount: genesisAddress, bucketIndex, }) - spinner.succeed('Launch pool bucket added successfully!') + const result = await umiSendAndConfirmTransaction(this.context.umi, transaction) + + // Set end behaviors in a separate transaction if provided + let behaviorsSignature: string | undefined + let behaviorsError: unknown + if (endBehaviors.length > 0) { + try { + spinner.text = 'Setting end behaviors...' + const setBehaviorsTx = setLaunchPoolBucketV2Behaviors(this.context.umi, { + genesisAccount: genesisAddress, + bucket: bucketPda, + authority: this.context.signer, + payer: this.context.payer, + padding: new Array(3).fill(0), + endBehaviors, + }) + + const behaviorsResult = await umiSendAndConfirmTransaction(this.context.umi, setBehaviorsTx) + behaviorsSignature = txSignatureToString(behaviorsResult.transaction.signature as Uint8Array) + } catch (error) { + behaviorsError = error + } + } + + if (behaviorsError) { + spinner.warn('Bucket created but failed to set end behaviors') + this.warn( + `End behaviors were not set. Run setLaunchPoolBucketV2Behaviors manually for bucket ${bucketPda}.\n` + + `Error: ${behaviorsError instanceof Error ? behaviorsError.message : String(behaviorsError)}` + ) + } else { + spinner.succeed('Launch pool bucket added successfully!') + } this.log('') this.logSuccess(`Launch Pool Bucket Added`) @@ -296,6 +327,18 @@ Use Unix timestamps for absolute times.` 'transaction' ) ) + if (behaviorsSignature) { + this.log('') + this.log(`Behaviors Transaction: ${behaviorsSignature}`) + this.log( + generateExplorerUrl( + this.context.explorer, + this.context.chain, + behaviorsSignature, + 'transaction' + ) + ) + } } catch (error) { spinner.fail('Failed to add launch pool bucket') diff --git a/src/commands/genesis/index.ts b/src/commands/genesis/index.ts index 4969b9e..f3eead6 100644 --- a/src/commands/genesis/index.ts +++ b/src/commands/genesis/index.ts @@ -29,6 +29,7 @@ export default class Genesis extends Command { this.log('') this.log('Subcommand groups:') this.log(' genesis bucket Manage buckets (add-launch-pool, add-presale, add-unlocked, fetch)') + this.log(' genesis launch Create and register launches via the Genesis API') this.log(' genesis presale Presale deposit and claim commands') this.log('') this.log('Run "mplx genesis --help" for more information about a command.') diff --git a/src/commands/genesis/launch/create.ts b/src/commands/genesis/launch/create.ts new file mode 100644 index 0000000..d2d7115 --- /dev/null +++ b/src/commands/genesis/launch/create.ts @@ -0,0 +1,266 @@ +import { + CreateLaunchInput, + GenesisApiConfig, + LockedAllocation, + SvmNetwork, + createAndRegisterLaunch, +} from '@metaplex-foundation/genesis' +import { Flags } from '@oclif/core' +import ora from 'ora' + +import { TransactionCommand } from '../../../TransactionCommand.js' +import { generateExplorerUrl } from '../../../explorers.js' +import { readJsonSync } from '../../../lib/file.js' +import { detectSvmNetwork, txSignatureToString } from '../../../lib/util.js' + +export default class GenesisLaunchCreate extends TransactionCommand { + static override description = `Create a new token launch via the Genesis API. + +This is an all-in-one command that: + 1. Calls the Genesis API to build the on-chain transactions + 2. Signs and sends them to the network + 3. Registers the launch on the Metaplex platform + +The Genesis API handles creating the genesis account, mint, launch pool bucket, +and optional locked allocations in a single flow. + +Total token supply is fixed at 1,000,000,000. The deposit period is 48 hours.` + + static override examples = [ + '$ 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 --name "My Token" --symbol "MTK" --image "https://gateway.irys.xyz/abc123" --tokenAllocation 500000000 --depositStartTime 1709251200 --raiseGoal 200 --raydiumLiquidityBps 5000 --fundsRecipient
--quoteMint USDC', + '$ 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 = { + // Token metadata + name: Flags.string({ + char: 'n', + description: 'Name of the token (1-32 characters)', + required: true, + }), + symbol: Flags.string({ + char: 's', + description: 'Symbol of the token (1-10 characters)', + required: true, + }), + image: Flags.string({ + description: 'Token image URL (must start with https://gateway.irys.xyz/)', + required: true, + }), + description: Flags.string({ + description: 'Token description (max 250 characters)', + required: false, + }), + website: Flags.string({ + description: 'Project website URL', + required: false, + }), + twitter: Flags.string({ + description: 'Project Twitter URL', + required: false, + }), + telegram: Flags.string({ + description: 'Project Telegram URL', + required: false, + }), + + // Launchpool config + tokenAllocation: Flags.integer({ + description: 'Launch pool token allocation (portion of 1B total supply)', + required: true, + }), + depositStartTime: Flags.string({ + description: 'Deposit start time (ISO date string or unix timestamp). Deposit period is 48 hours.', + required: true, + }), + raiseGoal: Flags.integer({ + description: 'Raise goal in whole units (e.g., 200 for 200 SOL)', + required: true, + }), + raydiumLiquidityBps: Flags.integer({ + description: 'Raydium liquidity in basis points (2000-10000, i.e. 20%-100%)', + required: true, + }), + fundsRecipient: Flags.string({ + description: 'Funds recipient wallet address', + required: true, + }), + + // Optional + lockedAllocations: Flags.string({ + description: 'Path to JSON file with locked allocation configs', + required: false, + }), + quoteMint: Flags.string({ + description: 'Quote mint: SOL (default), USDC, or a mint address', + default: 'SOL', + }), + network: Flags.option({ + description: 'Network override (auto-detected from RPC if not set)', + options: ['solana-mainnet', 'solana-devnet'] as const, + required: false, + })(), + apiUrl: Flags.string({ + description: 'Genesis API base URL', + default: 'https://api.metaplex.com', + required: false, + }), + } + + static override usage = 'genesis launch create [FLAGS]' + + public async run(): Promise { + const { flags } = await this.parse(GenesisLaunchCreate) + + if (flags.raydiumLiquidityBps < 2000 || flags.raydiumLiquidityBps > 10000) { + this.error('raydiumLiquidityBps must be between 2000 and 10000 (20%-100%)') + } + + 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) + + // Parse locked allocations from JSON file if provided + let lockedAllocations: LockedAllocation[] | undefined + if (flags.lockedAllocations) { + const filePath = flags.lockedAllocations + let parsed: unknown + try { + parsed = readJsonSync(filePath) + } catch (err) { + if (err && typeof err === 'object' && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Locked allocations file not found: ${filePath}`) + } + throw err + } + + if (!Array.isArray(parsed)) { + throw new Error('Locked allocations file must contain a JSON array') + } + + const validTimeUnits = new Set(['SECOND', 'MINUTE', 'HOUR', 'DAY', 'WEEK', 'TWO_WEEKS', 'MONTH', 'QUARTER', 'YEAR']) + for (let i = 0; i < parsed.length; i++) { + const entry = parsed[i] + if (typeof entry.name !== 'string' || entry.name.length === 0) { + throw new Error(`Locked allocation [${i}]: "name" must be a non-empty string`) + } + if (typeof entry.recipient !== 'string' || entry.recipient.length === 0) { + throw new Error(`Locked allocation [${i}]: "recipient" must be a non-empty string`) + } + if (typeof entry.tokenAmount !== 'number' || entry.tokenAmount <= 0) { + throw new Error(`Locked allocation [${i}]: "tokenAmount" must be a positive number`) + } + if (typeof entry.vestingStartTime !== 'string' || entry.vestingStartTime.length === 0) { + throw new Error(`Locked allocation [${i}]: "vestingStartTime" must be a non-empty date string`) + } + if (!entry.vestingDuration || typeof entry.vestingDuration.value !== 'number' || !validTimeUnits.has(entry.vestingDuration.unit)) { + throw new Error(`Locked allocation [${i}]: "vestingDuration" must have a numeric "value" and a valid "unit"`) + } + if (!validTimeUnits.has(entry.unlockSchedule)) { + throw new Error(`Locked allocation [${i}]: "unlockSchedule" must be a valid time unit`) + } + if (entry.cliff !== undefined) { + if (typeof entry.cliff !== 'object' || entry.cliff === null) { + throw new Error(`Locked allocation [${i}]: "cliff" must be an object`) + } + if (!entry.cliff.duration || typeof entry.cliff.duration.value !== 'number' || !validTimeUnits.has(entry.cliff.duration.unit)) { + throw new Error(`Locked allocation [${i}]: "cliff.duration" must have a numeric "value" and a valid "unit"`) + } + if (entry.cliff.unlockAmount !== undefined && (typeof entry.cliff.unlockAmount !== 'number' || entry.cliff.unlockAmount < 0)) { + throw new Error(`Locked allocation [${i}]: "cliff.unlockAmount" must be a non-negative number`) + } + } + } + + lockedAllocations = parsed as LockedAllocation[] + } + + // 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 + + // Build input + const wallet = this.context.signer.publicKey.toString() + + const input: CreateLaunchInput = { + wallet, + token: { + name: flags.name, + symbol: flags.symbol, + image: flags.image, + ...(flags.description && { description: flags.description }), + ...(Object.keys(externalLinks).length > 0 && { externalLinks }), + }, + launchType: 'project', + launch: { + launchpool: { + tokenAllocation: flags.tokenAllocation, + depositStartTime: flags.depositStartTime, + raiseGoal: flags.raiseGoal, + raydiumLiquidityBps: flags.raydiumLiquidityBps, + fundsRecipient: flags.fundsRecipient, + }, + ...(lockedAllocations && { lockedAllocations }), + }, + network, + quoteMint: flags.quoteMint, + } + + const apiConfig: GenesisApiConfig = { + baseUrl: flags.apiUrl, + } + + 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, + input, + { commitment }, + ) + + spinner.succeed('Token launch created and registered successfully!') + + this.log('') + 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('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 + } + } +} diff --git a/src/commands/genesis/launch/index.ts b/src/commands/genesis/launch/index.ts new file mode 100644 index 0000000..60d2695 --- /dev/null +++ b/src/commands/genesis/launch/index.ts @@ -0,0 +1,14 @@ +import { Command } from '@oclif/core' + +export default class GenesisLaunch extends Command { + static override description = 'Genesis Launch Commands - Create and register token launches via the Genesis API' + + static override examples = [ + '<%= config.bin %> genesis launch create --name "My Token" --symbol "MTK" --image "https://gateway.irys.xyz/..." --tokenAllocation 500000000 --depositStartTime 2025-03-01T00:00:00Z --raiseGoal 200 --raydiumLiquidityBps 5000 --fundsRecipient
', + '<%= config.bin %> genesis launch register ', + ] + + public async run(): Promise { + await this.parse(GenesisLaunch) + } +} diff --git a/src/commands/genesis/launch/register.ts b/src/commands/genesis/launch/register.ts new file mode 100644 index 0000000..c9e0cf7 --- /dev/null +++ b/src/commands/genesis/launch/register.ts @@ -0,0 +1,124 @@ +import { + CreateLaunchInput, + GenesisApiConfig, + SvmNetwork, + registerLaunch, +} from '@metaplex-foundation/genesis' +import { Args, Flags } from '@oclif/core' +import ora from 'ora' + +import { TransactionCommand } from '../../../TransactionCommand.js' +import { readJsonSync } from '../../../lib/file.js' +import { detectSvmNetwork } from '../../../lib/util.js' + +export default class GenesisLaunchRegister extends TransactionCommand { + static override description = `Register an existing genesis account with the Metaplex platform. + +Use this command if you created a genesis account using the low-level CLI commands +(genesis create, bucket add-launch-pool, etc.) and want to register it on the +Metaplex platform to get a public launch page. + +Requires the same launch configuration that was used to create the genesis account, +provided as a JSON file via --launchConfig.` + + static override examples = [ + '$ mplx genesis launch register --launchConfig launch.json', + '$ mplx genesis launch register --launchConfig launch.json --network solana-devnet', + ] + + static override args = { + genesisAccount: Args.string({ + description: 'Genesis account address to register', + required: true, + }), + } + + static override flags = { + launchConfig: Flags.string({ + description: 'Path to JSON file with the launch configuration (same format as launch create input)', + required: true, + }), + network: Flags.option({ + description: 'Network override (auto-detected from RPC if not set)', + options: ['solana-mainnet', 'solana-devnet'] as const, + required: false, + })(), + apiUrl: Flags.string({ + description: 'Genesis API base URL', + default: 'https://api.metaplex.com', + required: false, + }), + } + + static override usage = 'genesis launch register [FLAGS]' + + public async run(): Promise { + const { args, flags } = await this.parse(GenesisLaunchRegister) + + const spinner = ora('Registering genesis account...').start() + + try { + // Detect network from chain if not specified + const network: SvmNetwork = flags.network ?? detectSvmNetwork(this.context.chain) + + // Read launch config from JSON file + const filePath = flags.launchConfig + let launchConfig: CreateLaunchInput + try { + launchConfig = readJsonSync(filePath) as CreateLaunchInput + } catch (err) { + if (err && typeof err === 'object' && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Launch config file not found: ${filePath}`) + } + throw err + } + + // Validate required top-level fields + if (!launchConfig.token || typeof launchConfig.token !== 'object') { + throw new Error('Launch config is missing required "token" object (must include name, symbol, image)') + } + if (!launchConfig.launch || typeof launchConfig.launch !== 'object') { + throw new Error('Launch config is missing required "launch" object (must include launchpool config)') + } + if (launchConfig.launchType !== 'project') { + throw new Error(`Launch config "launchType" must be "project", got "${launchConfig.launchType}"`) + } + + // Override network if specified via flag + launchConfig.network = network + + // Use the configured signer as wallet if not set in the config + if (!launchConfig.wallet) { + launchConfig.wallet = this.context.signer.publicKey.toString() + } + + const apiConfig: GenesisApiConfig = { + baseUrl: flags.apiUrl, + } + + const result = await registerLaunch(this.context.umi, apiConfig, { + genesisAccount: args.genesisAccount, + createLaunchInput: launchConfig, + }) + + if (result.existing) { + spinner.succeed('Genesis account was already registered.') + } else { + spinner.succeed('Genesis account registered successfully!') + } + + this.log('') + this.logSuccess(`Launch ID: ${result.launch.id}`) + this.log(`Launch Link: ${result.launch.link}`) + this.log(`Token ID: ${result.token.id}`) + this.log(`Mint Address: ${result.token.mintAddress}`) + } catch (error) { + spinner.fail('Failed to register genesis account') + if (error && typeof error === 'object' && 'responseBody' in error) { + this.logJson((error as { responseBody: unknown }).responseBody) + } + + throw error + } + } +} diff --git a/src/commands/genesis/transition.ts b/src/commands/genesis/transition.ts index d317ee7..a3a358c 100644 --- a/src/commands/genesis/transition.ts +++ b/src/commands/genesis/transition.ts @@ -1,5 +1,5 @@ import { - transitionV2, + triggerBehaviorsV2, safeFetchGenesisAccountV2, findLaunchPoolBucketV2Pda, safeFetchLaunchPoolBucketV2, @@ -103,7 +103,7 @@ Requirements: // Build the transition transaction spinner.text = 'Executing transition...' - const transaction = transitionV2(this.context.umi, { + const transaction = triggerBehaviorsV2(this.context.umi, { genesisAccount: genesisAddress, primaryBucket: primaryBucketPda, baseMint: genesisAccount.baseMint, diff --git a/src/lib/util.ts b/src/lib/util.ts index ef5c7ec..75ecd7e 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -112,6 +112,10 @@ export enum RpcChain { Localnet } +export function detectSvmNetwork(chain: RpcChain): 'solana-mainnet' | 'solana-devnet' { + return chain === RpcChain.Mainnet ? 'solana-mainnet' : 'solana-devnet' +} + const GENESIS_HASH_MAP = new Map([ ['5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d', RpcChain.Mainnet], // Solana Mainnet (current) ['EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG', RpcChain.Devnet], // Solana Devnet (current) diff --git a/test/commands/genesis/genesis.launch.test.ts b/test/commands/genesis/genesis.launch.test.ts new file mode 100644 index 0000000..73eae6a --- /dev/null +++ b/test/commands/genesis/genesis.launch.test.ts @@ -0,0 +1,426 @@ +import { expect } from 'chai' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { runCli } from '../../runCli' +import { stripAnsi, createGenesisAccount, addLaunchPoolBucket } from './genesishelpers' + +/** Return an ISO timestamp offset from now by the given number of seconds. */ +function futureIso(offsetSeconds: number): string { + return new Date(Date.now() + offsetSeconds * 1000).toISOString() +} + +describe('genesis launch commands', () => { + + before(async () => { + await runCli(["toolbox", "sol", "airdrop", "100", "TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx"]) + await new Promise(resolve => setTimeout(resolve, 10000)) + await runCli(['toolbox', 'sol', 'wrap', '50']) + }) + + describe('genesis launch create', () => { + + it('fails when all required flags are missing', async () => { + try { + await runCli(['genesis', 'launch', 'create']) + 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('tokenAllocation') + expect(msg).to.contain('depositStartTime') + expect(msg).to.contain('raiseGoal') + expect(msg).to.contain('raydiumLiquidityBps') + expect(msg).to.contain('fundsRecipient') + } + }) + + const allFlags: Record = { + name: ['--name', 'My Token'], + symbol: ['--symbol', 'MTK'], + image: ['--image', 'https://gateway.irys.xyz/abc123'], + tokenAllocation: ['--tokenAllocation', '500000000'], + depositStartTime: ['--depositStartTime', futureIso(7 * 86400)], + raiseGoal: ['--raiseGoal', '200'], + raydiumLiquidityBps: ['--raydiumLiquidityBps', '5000'], + fundsRecipient: ['--fundsRecipient', 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx'], + } + + for (const omitted of Object.keys(allFlags)) { + it(`fails when required flags are missing (no --${omitted})`, async () => { + const cliInput = ['genesis', 'launch', 'create'] + for (const [key, pair] of Object.entries(allFlags)) { + if (key !== omitted) cliInput.push(...pair) + } + + try { + 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) + } + }) + } + + it('fails with non-existent locked allocations file', async () => { + const cliInput = [ + 'genesis', 'launch', 'create', + '--name', 'My Token', + '--symbol', 'MTK', + '--image', 'https://gateway.irys.xyz/abc123', + '--tokenAllocation', '500000000', + '--depositStartTime', futureIso(7 * 86400), + '--raiseGoal', '200', + '--raydiumLiquidityBps', '5000', + '--fundsRecipient', 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx', + '--lockedAllocations', path.join(os.tmpdir(), 'nonexistent-file-12345.json'), + ] + + try { + await runCli(cliInput) + expect.fail('Should have thrown an error for non-existent file') + } catch (error) { + expect((error as Error).message).to.contain('not found') + } + }) + + it('fails when locked allocations file is not a JSON array', async () => { + const tmpFile = path.join(os.tmpdir(), `test-bad-allocations-${process.pid}.json`) + fs.writeFileSync(tmpFile, JSON.stringify({ notAnArray: true })) + + try { + const cliInput = [ + 'genesis', 'launch', 'create', + '--name', 'My Token', + '--symbol', 'MTK', + '--image', 'https://gateway.irys.xyz/abc123', + '--tokenAllocation', '500000000', + '--depositStartTime', futureIso(7 * 86400), + '--raiseGoal', '200', + '--raydiumLiquidityBps', '5000', + '--fundsRecipient', 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx', + '--lockedAllocations', tmpFile, + ] + + await runCli(cliInput) + expect.fail('Should have thrown an error for non-array allocations') + } catch (error) { + expect((error as Error).message).to.contain('must contain a JSON array') + } finally { + fs.unlinkSync(tmpFile) + } + }) + + it('parses locked allocations file and reaches API call', async () => { + const tmpFile = path.join(os.tmpdir(), `test-locked-allocations-${process.pid}.json`) + fs.writeFileSync(tmpFile, JSON.stringify([ + { + name: 'Team', + recipient: 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx', + tokenAmount: 200000000, + vestingStartTime: futureIso(30 * 86400), + vestingDuration: { value: 1, unit: 'YEAR' }, + unlockSchedule: 'MONTH', + cliff: { + duration: { value: 3, unit: 'MONTH' }, + unlockAmount: 50000000, + }, + }, + ])) + + try { + const cliInput = [ + 'genesis', 'launch', 'create', + '--name', 'My Token', + '--symbol', 'MTK', + '--image', 'https://gateway.irys.xyz/abc123', + '--tokenAllocation', '500000000', + '--depositStartTime', futureIso(30 * 86400), + '--raiseGoal', '200', + '--raydiumLiquidityBps', '5000', + '--fundsRecipient', 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx', + '--lockedAllocations', tmpFile, + ] + + await runCli(cliInput) + expect.fail('Should have thrown an API error (API not available on localnet)') + } catch (error) { + // Should get past file parsing and validation, then fail at the API call + const msg = (error as Error).message + expect(msg).to.contain('Failed') + } finally { + fs.unlinkSync(tmpFile) + } + }) + + it('passes optional metadata flags and reaches API call', async () => { + const cliInput = [ + 'genesis', 'launch', 'create', + '--name', 'My Token', + '--symbol', 'MTK', + '--image', 'https://gateway.irys.xyz/abc123', + '--description', 'A test token with all metadata', + '--website', 'https://example.com', + '--twitter', 'https://x.com/testproject', + '--telegram', 'https://t.me/testproject', + '--tokenAllocation', '500000000', + '--depositStartTime', futureIso(30 * 86400), + '--raiseGoal', '200', + '--raydiumLiquidityBps', '5000', + '--fundsRecipient', 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx', + ] + + try { + await runCli(cliInput) + expect.fail('Should have thrown an API error (API not available on localnet)') + } catch (error) { + // Should get past flag parsing and validation (including metadata), + // then fail at the API call + const msg = (error as Error).message + expect(msg).to.contain('Failed') + } + }) + + it('calls the Genesis API with valid flags (expects API error since API is not local)', async () => { + const cliInput = [ + 'genesis', 'launch', 'create', + '--name', 'My Token', + '--symbol', 'MTK', + '--image', 'https://gateway.irys.xyz/abc123', + '--tokenAllocation', '500000000', + '--depositStartTime', futureIso(30 * 86400), + '--raiseGoal', '200', + '--raydiumLiquidityBps', '5000', + '--fundsRecipient', 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx', + ] + + try { + await runCli(cliInput) + expect.fail('Should have thrown an API error (API not available on localnet)') + } catch (error) { + // The command should get past flag parsing and validation, + // then fail at the API call + const msg = (error as Error).message + expect(msg).to.contain('Failed') + } + }) + }) + + describe('genesis launch register', () => { + + it('fails when genesis account argument is missing', async () => { + // Create a temp config file for the test + const tmpConfig = path.join(os.tmpdir(), `test-launch-config-${process.pid}.json`) + fs.writeFileSync(tmpConfig, JSON.stringify({ + wallet: 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx', + token: { name: 'Test', symbol: 'TST', image: 'https://gateway.irys.xyz/abc' }, + launchType: 'project', + launch: { + launchpool: { + tokenAllocation: 500000000, + depositStartTime: futureIso(30 * 86400), + raiseGoal: 200, + raydiumLiquidityBps: 5000, + fundsRecipient: 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx', + }, + }, + })) + + try { + const cliInput = [ + 'genesis', 'launch', 'register', + '--launchConfig', tmpConfig, + ] + + await runCli(cliInput) + expect.fail('Should have thrown an error for missing argument') + } catch (error) { + const msg = (error as Error).message + expect(msg).to.satisfy( + (m: string) => m.includes('genesisAccount') || m.includes('Missing'), + 'Expected error about missing genesisAccount argument' + ) + } finally { + fs.unlinkSync(tmpConfig) + } + }) + + it('fails when --launchConfig is missing', async () => { + const cliInput = [ + 'genesis', 'launch', 'register', + '11111111111111111111111111111111', + ] + + try { + await runCli(cliInput) + expect.fail('Should have thrown an error for missing flag') + } catch (error) { + expect((error as Error).message).to.contain('Missing required flag') + expect((error as Error).message).to.contain('launchConfig') + } + }) + + it('fails with non-existent launch config file', async () => { + const cliInput = [ + 'genesis', 'launch', 'register', + '11111111111111111111111111111111', + '--launchConfig', path.join(os.tmpdir(), 'nonexistent-config-12345.json'), + ] + + try { + await runCli(cliInput) + expect.fail('Should have thrown an error for non-existent file') + } catch (error) { + expect((error as Error).message).to.contain('not found') + } + }) + + it('calls the Genesis API with valid input (expects API error since API is not local)', async () => { + const tmpConfig = path.join(os.tmpdir(), `test-launch-config-register-${process.pid}.json`) + fs.writeFileSync(tmpConfig, JSON.stringify({ + wallet: 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx', + token: { name: 'Test', symbol: 'TST', image: 'https://gateway.irys.xyz/abc' }, + launchType: 'project', + launch: { + launchpool: { + tokenAllocation: 500000000, + depositStartTime: futureIso(30 * 86400), + raiseGoal: 200, + raydiumLiquidityBps: 5000, + fundsRecipient: 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx', + }, + }, + })) + + try { + const cliInput = [ + 'genesis', 'launch', 'register', + '11111111111111111111111111111111', + '--launchConfig', tmpConfig, + ] + + await runCli(cliInput) + expect.fail('Should have thrown an API error') + } catch (error) { + const msg = (error as Error).message + expect(msg).to.contain('Failed') + } finally { + fs.unlinkSync(tmpConfig) + } + }) + }) + + describe('add-launch-pool with claimSchedule (createClaimSchedule)', () => { + let genesisAddress: string + + before(async () => { + const result = await createGenesisAccount({ + name: 'ClaimSchedule Test', + symbol: 'CST', + totalSupply: '1000000000', + decimals: 9, + }) + + genesisAddress = result.genesisAddress + }) + + it('adds a launch pool bucket with claimSchedule', async () => { + const now = Math.floor(Date.now() / 1000) + const depositStart = (now - 3600).toString() + const depositEnd = (now + 86400).toString() + const claimStart = (now + 86400 + 1).toString() + const claimEnd = (now + 86400 * 365).toString() + + const claimSchedule = JSON.stringify({ + startTime: now + 86400 + 1, + endTime: now + 86400 * 100, + period: 86400, + cliffTime: now + 86400 + 1, + cliffAmountBps: 1000, + }) + + const cliInput = [ + 'genesis', 'bucket', 'add-launch-pool', + genesisAddress, + '--allocation', '1000000000', + '--depositStart', depositStart, + '--depositEnd', depositEnd, + '--claimStart', claimStart, + '--claimEnd', claimEnd, + '--claimSchedule', claimSchedule, + ] + + const { stdout, stderr, code } = await runCli(cliInput) + + const cleanStderr = stripAnsi(stderr) + const cleanStdout = stripAnsi(stdout) + + expect(code).to.equal(0) + expect(cleanStderr).to.contain('Launch pool bucket added successfully') + expect(cleanStdout).to.contain('Token Allocation: 1000000000') + }) + + it('fetches the bucket and verifies it was created', async () => { + await new Promise(resolve => setTimeout(resolve, 2000)) + + const { stdout, stderr, code } = await runCli([ + 'genesis', 'bucket', 'fetch', + genesisAddress, + '--bucketIndex', '0', + ]) + + const cleanStderr = stripAnsi(stderr) + const cleanStdout = stripAnsi(stdout) + + expect(code).to.equal(0) + expect(cleanStderr).to.contain('Bucket fetched successfully') + expect(cleanStdout).to.contain('Launch Pool Bucket') + expect(cleanStdout).to.contain('Base Token Allocation: 1000000000') + }) + }) + + describe('transition uses triggerBehaviorsV2', () => { + let genesisAddress: string + + before(async () => { + const result = await createGenesisAccount({ + name: 'Transition Test', + symbol: 'TRN', + totalSupply: '1000000000', + decimals: 9, + }) + + genesisAddress = result.genesisAddress + + const now = Math.floor(Date.now() / 1000) + await addLaunchPoolBucket(genesisAddress, { + allocation: '1000000000', + depositStart: (now - 3600).toString(), + depositEnd: (now + 86400).toString(), + claimStart: (now + 86400 + 1).toString(), + claimEnd: (now + 86400 * 365).toString(), + }) + }) + + it('transition invokes triggerBehaviorsV2 on-chain', async () => { + // The transition command invokes triggerBehaviorsV2. + // It will fail because the account is not finalized, + // but the program log confirms the renamed function is called. + try { + await runCli([ + 'genesis', 'transition', genesisAddress, + '--bucketIndex', '0', + ]) + expect.fail('Should have thrown an error since account is not finalized') + } catch (error) { + const msg = (error as Error).message + // The program log shows "TriggerBehaviorsV2" confirming the rename works + expect(msg).to.contain('TriggerBehaviorsV2') + } + }) + }) +}) diff --git a/test/commands/genesis/genesishelpers.ts b/test/commands/genesis/genesishelpers.ts index d36f4df..39324b6 100644 --- a/test/commands/genesis/genesishelpers.ts +++ b/test/commands/genesis/genesishelpers.ts @@ -106,7 +106,7 @@ const addLaunchPoolBucket = async ( const allocation = options?.allocation ?? '500000000' const depositStart = options?.depositStart ?? (now - 3600).toString() const depositEnd = options?.depositEnd ?? (now + 86400).toString() - const claimStart = options?.claimStart ?? (now + 86400).toString() + const claimStart = options?.claimStart ?? (now + 86400 + 1).toString() const claimEnd = options?.claimEnd ?? (now + 86400 * 365).toString() const cliInput = [ @@ -165,7 +165,7 @@ const addPresaleBucket = async ( const quoteCap = options?.quoteCap ?? '1000000000' const depositStart = options?.depositStart ?? (now - 3600).toString() const depositEnd = options?.depositEnd ?? (now + 86400).toString() - const claimStart = options?.claimStart ?? (now + 86400).toString() + const claimStart = options?.claimStart ?? (now + 86400 + 1).toString() const claimEnd = options?.claimEnd ?? (now + 86400 * 365).toString() const bucketIndex = (options?.bucketIndex ?? 0).toString() diff --git a/test/runCli.ts b/test/runCli.ts index 51f4bb2..2ec0e1b 100644 --- a/test/runCli.ts +++ b/test/runCli.ts @@ -12,7 +12,6 @@ export const runCli = (args: string[], stdin?: string[]): Promise<{ stdout: stri let stdout = '' let stderr = '' - let hasError = false child.stdout.on('data', (data) => { const str = data.toString() @@ -22,27 +21,19 @@ export const runCli = (args: string[], stdin?: string[]): Promise<{ stdout: stri child.stderr.on('data', (data) => { const str = data.toString() - // Check if this is an actual error message - if (str.toLowerCase().includes('error') || str.toLowerCase().includes('failed')) { - hasError = true - } // console.log('stderr:', str) stderr += str }) child.on('error', (error) => { - // console.error('Process error:', error) - hasError = true reject(error) }) child.on('close', (code) => { - // If we have an error in stderr or non-zero exit code, treat as error - if (hasError || code !== 0) { + if (code !== 0) { reject(new Error(`Process failed with code ${code}\nstderr: ${stderr}`)) } else { - // console.log('Process exited with code:', code) - resolve({ stdout, stderr, code: code || 0 }) + resolve({ stdout, stderr, code: 0 }) } })