#!/usr/bin/env ts-node import * as fs from 'fs'; import * as path from 'path'; import { program } from 'commander'; import * as anchor from '@project-serum/anchor'; import BN from 'bn.js'; import fetch from 'node-fetch'; import { fromUTF8Array, parseDate, parsePrice } from './helpers/various'; import { Token, TOKEN_PROGRAM_ID } from '@solana/spl-token'; import { PublicKey } from '@solana/web3.js'; import { CACHE_PATH, CONFIG_ARRAY_START, CONFIG_LINE_SIZE, EXTENSION_JSON, EXTENSION_PNG, } from './helpers/constants'; import { getCandyMachineAddress, loadCandyProgram, loadWalletKey, } from './helpers/accounts'; import { Config } from './types'; import { upload } from './commands/upload'; import { verifyTokenMetadata } from './commands/verifyTokenMetadata'; import { loadCache, saveCache } from './helpers/cache'; import { mint } from './commands/mint'; import { signMetadata } from './commands/sign'; import { signAllMetadataFromCandyMachine } from './commands/signAll'; import log from 'loglevel'; program.version('0.0.2'); if (!fs.existsSync(CACHE_PATH)) { fs.mkdirSync(CACHE_PATH); } log.setLevel(log.levels.INFO); programCommand('upload') .argument( '', 'Directory containing images named from 0-n', val => { return fs.readdirSync(`${val}`).map(file => path.join(val, file)); }, ) .option('-n, --number ', 'Number of images to upload') .option( '-s, --storage ', 'Database to use for storage (arweave, ipfs)', 'arweave', ) .option( '--ipfs-infura-project-id', 'Infura IPFS project id (required if using IPFS)', ) .option( '--ipfs-infura-secret', 'Infura IPFS scret key (required if using IPFS)', ) .option('--no-retain-authority', 'Do not retain authority to update metadata') .action(async (files: string[], options, cmd) => { const { number, keypair, env, cacheName, storage, ipfsInfuraProjectId, ipfsInfuraSecret, retainAuthority, } = cmd.opts(); if (storage === 'ipfs' && (!ipfsInfuraProjectId || !ipfsInfuraSecret)) { throw new Error( 'IPFS selected as storage option but Infura project id or secret key were not provided.', ); } if (!(storage === 'arweave' || storage === 'ipfs')) { throw new Error("Storage option must either be 'arweave' or 'ipfs'."); } const ipfsCredentials = { projectId: ipfsInfuraProjectId, secretKey: ipfsInfuraSecret, }; const pngFileCount = files.filter(it => { return it.endsWith(EXTENSION_PNG); }).length; const jsonFileCount = files.filter(it => { return it.endsWith(EXTENSION_JSON); }).length; const parsedNumber = parseInt(number); const elemCount = parsedNumber ? parsedNumber : pngFileCount; if (pngFileCount !== jsonFileCount) { throw new Error( `number of png files (${pngFileCount}) is different than the number of json files (${jsonFileCount})`, ); } if (elemCount < pngFileCount) { throw new Error( `max number (${elemCount})cannot be smaller than the number of elements in the source folder (${pngFileCount})`, ); } log.info(`Beginning the upload for ${elemCount} (png+json) pairs`); const startMs = Date.now(); log.info('started at: ' + startMs.toString()); let warn = false; for (;;) { const successful = await upload( files, cacheName, env, keypair, elemCount, storage, retainAuthority, ipfsCredentials, ); if (successful) { warn = false; break; } else { warn = true; log.warn('upload was not successful, rerunning'); } } const endMs = Date.now(); const timeTaken = new Date(endMs - startMs).toISOString().substr(11, 8); log.info( `ended at: ${new Date(endMs).toISOString()}. time taken: ${timeTaken}`, ); if (warn) { log.info('not all images have been uplaoded, rerun this step.'); } }); programCommand('verify_token_metadata') .argument( '', 'Directory containing images and metadata files named from 0-n', val => { return fs .readdirSync(`${val}`) .map(file => path.join(process.cwd(), val, file)); }, ) .option('-n, --number ', 'Number of images to upload') .action((files: string[], options, cmd) => { const { number } = cmd.opts(); const startMs = Date.now(); log.info('started at: ' + startMs.toString()); verifyTokenMetadata({ files, uploadElementsCount: number }); const endMs = Date.now(); const timeTaken = new Date(endMs - startMs).toISOString().substr(11, 8); log.info( `ended at: ${new Date(endMs).toString()}. time taken: ${timeTaken}`, ); }); programCommand('verify').action(async (directory, cmd) => { const { env, keypair, cacheName } = cmd.opts(); const cacheContent = loadCache(cacheName, env); const walletKeyPair = loadWalletKey(keypair); const anchorProgram = await loadCandyProgram(walletKeyPair, env); const configAddress = new PublicKey(cacheContent.program.config); const config = await anchorProgram.provider.connection.getAccountInfo( configAddress, ); let allGood = true; const keys = Object.keys(cacheContent.items); for (let i = 0; i < keys.length; i++) { log.debug('Looking at key ', i); const key = keys[i]; const thisSlice = config.data.slice( CONFIG_ARRAY_START + 4 + CONFIG_LINE_SIZE * i, CONFIG_ARRAY_START + 4 + CONFIG_LINE_SIZE * (i + 1), ); const name = fromUTF8Array([...thisSlice.slice(4, 36)]); const uri = fromUTF8Array([...thisSlice.slice(40, 240)]); const cacheItem = cacheContent.items[key]; if (!name.match(cacheItem.name) || !uri.match(cacheItem.link)) { //leaving here for debugging reasons, but it's pretty useless. if the first upload fails - all others are wrong // log.info( // `Name (${name}) or uri (${uri}) didnt match cache values of (${cacheItem.name})` + // `and (${cacheItem.link}). marking to rerun for image`, // key, // ); cacheItem.onChain = false; allGood = false; } else { const json = await fetch(cacheItem.link); if (json.status == 200 || json.status == 204 || json.status == 202) { const body = await json.text(); const parsed = JSON.parse(body); if (parsed.image) { const check = await fetch(parsed.image); if ( check.status == 200 || check.status == 204 || check.status == 202 ) { const text = await check.text(); if (!text.match(/Not found/i)) { if (text.length == 0) { log.debug( 'Name', name, 'with', uri, 'has zero length, failing', ); cacheItem.onChain = false; allGood = false; } else { log.debug('Name', name, 'with', uri, 'checked out'); } } else { log.debug( 'Name', name, 'with', uri, 'never got uploaded to arweave, failing', ); cacheItem.onChain = false; allGood = false; } } else { log.debug( 'Name', name, 'with', uri, 'returned non-200 from uploader', check.status, ); cacheItem.onChain = false; allGood = false; } } else { log.debug('Name', name, 'with', uri, 'lacked image in json, failing'); cacheItem.onChain = false; allGood = false; } } else { log.debug('Name', name, 'with', uri, 'returned no json from link'); cacheItem.onChain = false; allGood = false; } } } if (!allGood) { saveCache(cacheName, env, cacheContent); throw new Error( `not all NFTs checked out. check out logs above for details`, ); } const configData = (await anchorProgram.account.config.fetch( configAddress, )) as Config; const lineCount = new BN(config.data.slice(247, 247 + 4), undefined, 'le'); log.info( `uploaded (${lineCount.toNumber()}) out of (${ configData.data.maxNumberOfLines })`, ); if (configData.data.maxNumberOfLines > lineCount.toNumber()) { throw new Error( `predefined number of NFTs (${ configData.data.maxNumberOfLines }) is smaller than the uploaded one (${lineCount.toNumber()})`, ); } else { log.info('ready to deploy!'); } saveCache(cacheName, env, cacheContent); }); programCommand('verify_price') .option('-p, --price ') .option('--cache-path ') .action(async (directory, cmd) => { const { keypair, env, price, cacheName, cachePath } = cmd.opts(); const lamports = parsePrice(price); if (isNaN(lamports)) { return log.error(`verify_price requires a --price to be set`); } log.info(`Expected price is: ${lamports}`); const cacheContent = loadCache(cacheName, env, cachePath); if (!cacheContent) { return log.error( `No cache found, can't continue. Make sure you are in the correct directory where the assets are located or use the --cache-path option.`, ); } const walletKeyPair = loadWalletKey(keypair); const anchorProgram = await loadCandyProgram(walletKeyPair, env); const candyAddress = new PublicKey(cacheContent.candyMachineAddress); const machine = await anchorProgram.account.candyMachine.fetch( candyAddress, ); //@ts-ignore const candyMachineLamports = machine.data.price.toNumber(); log.info(`Candymachine price is: ${candyMachineLamports}`); if (lamports != candyMachineLamports) { throw new Error(`Expected price and CandyMachine's price do not match!`); } log.info(`Good to go!`); }); programCommand('show') .option('--cache-path ') .action(async (directory, cmd) => { const { keypair, env, cacheName, cachePath } = cmd.opts(); const cacheContent = loadCache(cacheName, env, cachePath); if (!cacheContent) { return log.error( `No cache found, can't continue. Make sure you are in the correct directory where the assets are located or use the --cache-path option.`, ); } const walletKeyPair = loadWalletKey(keypair); const anchorProgram = await loadCandyProgram(walletKeyPair, env); const [candyMachine] = await getCandyMachineAddress( new PublicKey(cacheContent.program.config), cacheContent.program.uuid, ); try { const machine = await anchorProgram.account.candyMachine.fetch( candyMachine, ); log.info('...Candy Machine...'); //@ts-ignore log.info('authority: ', machine.authority.toBase58()); //@ts-ignore log.info('wallet: ', machine.wallet.toBase58()); //@ts-ignore log.info('tokenMint: ', machine.tokenMint.toBase58()); //@ts-ignore log.info('config: ', machine.config.toBase58()); //@ts-ignore log.info('uuid: ', machine.data.uuid); //@ts-ignore log.info('price: ', machine.data.price.toNumber()); //@ts-ignore log.info('itemsAvailable: ', machine.data.itemsAvailable.toNumber()); log.info( 'goLiveDate: ', //@ts-ignore machine.data.goLiveDate ? //@ts-ignore new Date(machine.data.goLiveDate * 1000) : 'N/A', ); } catch (e) { console.log('No machine found'); } const config = await anchorProgram.account.config.fetch( cacheContent.program.config, ); log.info('...Config...'); //@ts-ignore log.info('authority: ', config.authority); //@ts-ignore log.info('symbol: ', config.data.symbol); //@ts-ignore log.info('sellerFeeBasisPoints: ', config.data.sellerFeeBasisPoints); //@ts-ignore log.info('creators: '); //@ts-ignore config.data.creators.map(c => log.info(c.address.toBase58(), 'at', c.share, '%'), ), //@ts-ignore log.info('maxSupply: ', config.data.maxSupply.toNumber()); //@ts-ignore log.info('retainAuthority: ', config.data.retainAuthority); //@ts-ignore log.info('maxNumberOfLines: ', config.data.maxNumberOfLines); }); programCommand('create_candy_machine') .option( '-p, --price ', 'Price denominated in SOL or spl-token override', '1', ) .option( '-t, --spl-token ', 'SPL token used to price NFT mint. To use SOL leave this empty.', ) .option( '-a, --spl-token-account ', 'SPL token account that receives mint payments. Only required if spl-token is specified.', ) .option( '-s, --sol-treasury-account ', 'SOL account that receives mint payments.', ) .action(async (directory, cmd) => { const { keypair, env, price, cacheName, splToken, splTokenAccount, solTreasuryAccount, } = cmd.opts(); let parsedPrice = parsePrice(price); const cacheContent = loadCache(cacheName, env); const walletKeyPair = loadWalletKey(keypair); const anchorProgram = await loadCandyProgram(walletKeyPair, env); let wallet = walletKeyPair.publicKey; const remainingAccounts = []; if (splToken || splTokenAccount) { if (solTreasuryAccount) { throw new Error( 'If spl-token-account or spl-token is set then sol-treasury-account cannot be set', ); } if (!splToken) { throw new Error( 'If spl-token-account is set, spl-token must also be set', ); } const splTokenKey = new PublicKey(splToken); const splTokenAccountKey = new PublicKey(splTokenAccount); if (!splTokenAccount) { throw new Error( 'If spl-token is set, spl-token-account must also be set', ); } const token = new Token( anchorProgram.provider.connection, splTokenKey, TOKEN_PROGRAM_ID, walletKeyPair, ); const mintInfo = await token.getMintInfo(); if (!mintInfo.isInitialized) { throw new Error(`The specified spl-token is not initialized`); } const tokenAccount = await token.getAccountInfo(splTokenAccountKey); if (!tokenAccount.isInitialized) { throw new Error(`The specified spl-token-account is not initialized`); } if (!tokenAccount.mint.equals(splTokenKey)) { throw new Error( `The spl-token-account's mint (${tokenAccount.mint.toString()}) does not match specified spl-token ${splTokenKey.toString()}`, ); } wallet = splTokenAccountKey; parsedPrice = parsePrice(price, 10 ** mintInfo.decimals); remainingAccounts.push({ pubkey: splTokenKey, isWritable: false, isSigner: false, }); } if (solTreasuryAccount) { wallet = new PublicKey(solTreasuryAccount); } const config = new PublicKey(cacheContent.program.config); const [candyMachine, bump] = await getCandyMachineAddress( config, cacheContent.program.uuid, ); await anchorProgram.rpc.initializeCandyMachine( bump, { uuid: cacheContent.program.uuid, price: new anchor.BN(parsedPrice), itemsAvailable: new anchor.BN(Object.keys(cacheContent.items).length), goLiveDate: null, }, { accounts: { candyMachine, wallet, config: config, authority: walletKeyPair.publicKey, payer: walletKeyPair.publicKey, systemProgram: anchor.web3.SystemProgram.programId, rent: anchor.web3.SYSVAR_RENT_PUBKEY, }, signers: [], remainingAccounts, }, ); cacheContent.candyMachineAddress = candyMachine.toBase58(); saveCache(cacheName, env, cacheContent); log.info( `create_candy_machine finished. candy machine pubkey: ${candyMachine.toBase58()}`, ); }); programCommand('update_candy_machine') .option( '-d, --date ', 'timestamp - eg "04 Dec 1995 00:12:00 GMT" or "now"', ) .option('-p, --price ', 'SOL price') .action(async (directory, cmd) => { const { keypair, env, date, price, cacheName } = cmd.opts(); const cacheContent = loadCache(cacheName, env); const secondsSinceEpoch = date ? parseDate(date) : null; const lamports = price ? parsePrice(price) : null; const walletKeyPair = loadWalletKey(keypair); const anchorProgram = await loadCandyProgram(walletKeyPair, env); const candyMachine = new PublicKey(cacheContent.candyMachineAddress); const tx = await anchorProgram.rpc.updateCandyMachine( lamports ? new anchor.BN(lamports) : null, secondsSinceEpoch ? new anchor.BN(secondsSinceEpoch) : null, { accounts: { candyMachine, authority: walletKeyPair.publicKey, }, }, ); cacheContent.startDate = secondsSinceEpoch; saveCache(cacheName, env, cacheContent); if (date) log.info( ` - updated startDate timestamp: ${secondsSinceEpoch} (${date})`, ); if (lamports) log.info(` - updated price: ${lamports} lamports (${price} SOL)`); log.info('updated_candy_machine finished', tx); }); programCommand('mint_one_token').action(async (directory, cmd) => { const { keypair, env, cacheName } = cmd.opts(); const cacheContent = loadCache(cacheName, env); const configAddress = new PublicKey(cacheContent.program.config); const tx = await mint(keypair, env, configAddress); log.info('mint_one_token finished', tx); }); programCommand('sign') // eslint-disable-next-line @typescript-eslint/no-unused-vars .option('-m, --metadata ', 'base58 metadata account id') .action(async (directory, cmd) => { const { keypair, env, metadata } = cmd.opts(); await signMetadata(metadata, keypair, env); }); programCommand('sign_all') .option('-b, --batch-size ', 'Batch size', '10') .option('-d, --daemon', 'Run signing continuously', false) .action(async (directory, cmd) => { const { keypair, env, cacheName, batchSize, daemon } = cmd.opts(); const cacheContent = loadCache(cacheName, env); const walletKeyPair = loadWalletKey(keypair); const anchorProgram = await loadCandyProgram(walletKeyPair, env); const candyAddress = cacheContent.candyMachineAddress; const batchSizeParsed = parseInt(batchSize); if (!parseInt(batchSize)) { throw new Error('Batch size needs to be an integer!'); } log.debug('Creator pubkey: ', walletKeyPair.publicKey.toBase58()); log.debug('Environment: ', env); log.debug('Candy machine address: ', candyAddress); log.debug('Batch Size: ', batchSizeParsed); await signAllMetadataFromCandyMachine( anchorProgram.provider.connection, walletKeyPair, candyAddress, batchSizeParsed, daemon, ); }); function programCommand(name: string) { return program .command(name) .option( '-e, --env ', 'Solana cluster env name', 'devnet', //mainnet-beta, testnet, devnet ) .option( '-k, --keypair ', `Solana wallet location`, '--keypair not provided', ) .option('-l, --log-level ', 'log level', setLogLevel) .option('-c, --cache-name ', 'Cache file name', 'temp'); } // eslint-disable-next-line @typescript-eslint/no-unused-vars function setLogLevel(value, prev) { if (value === undefined || value === null) { return; } log.info('setting the log value to: ' + value); log.setLevel(value); } program.parse(process.argv);