xchange-create
Version:
Create Xchange Project
1,109 lines (1,074 loc) โข 45.9 kB
JavaScript
import { execa } from 'execa';
import url, { fileURLToPath } from 'url';
import path from 'path';
import * as fs from 'fs';
import fs__default, { lstatSync, readdirSync, existsSync, promises } from 'fs';
import mergeJsonStr from 'merge-packages';
import ncp from 'ncp';
import { promisify } from 'util';
import { spawn } from 'child_process';
import chalk from 'chalk';
import Listr from 'listr';
import { parse, stringify } from 'envfile';
import arg from 'arg';
import inquirer from 'inquirer';
import { createPublicClient, http } from 'viem';
import { base, polygon, bsc, mainnet, arbitrum, optimism, baseSepolia, sepolia } from 'viem/chains';
import { Table } from 'console-table-printer';
import axios from 'axios';
const isExtension = (item) => item !== null;
/**
* This function makes sure that the `T` generic type is narrowed down to
* whatever `extensions` are passed in the question prop. That way we can type
* check the `default` prop is not using any valid extension, but only one
* already provided in the `extensions` prop.
*
* Questions can be created without this function, just using a normal object,
* but `default` type will be any valid Extension.
*/
const typedQuestion = (question) => question;
const typedContractTypeQuestion = (question) => question;
const isDefined = (item) => item !== undefined && item !== null;
const extensionWithSubextensions = (extension) => {
return Object.prototype.hasOwnProperty.call(extension, "extensions");
};
const baseDir = "base";
const extensionDict = {};
const currentFileUrl = import.meta.url;
const templatesDirectory = path.resolve(decodeURI(fileURLToPath(currentFileUrl)), "../../templates");
/**
* This function has side effects. It generates the extensionDict.
*
* @param basePath the path at which to start the traverse
* @returns the extensions found in this path. Useful for the recursion
*/
const traverseExtensions = async (basePath) => {
const extensionsPath = path.resolve(basePath, "extensions");
let extensions;
try {
extensions = fs__default.readdirSync(extensionsPath);
}
catch (error) {
return [];
}
await Promise.all(extensions.map(async (ext) => {
const extPath = path.resolve(extensionsPath, ext);
const configPath = path.resolve(extPath, "config.json");
let config = {};
try {
config = JSON.parse(fs__default.readFileSync(configPath, "utf8"));
}
catch (error) {
if (fs__default.existsSync(configPath)) {
throw new Error(`Couldn't parse existing config.json file.
Extension: ${ext};
Config file path: ${configPath}`);
}
}
let name = config.name ?? ext;
let value = ext;
const subExtensions = await traverseExtensions(extPath);
const hasSubExtensions = subExtensions.length !== 0;
const extDescriptor = {
name,
value,
path: extPath,
extensions: subExtensions,
extends: config.extends,
};
if (!hasSubExtensions) {
delete extDescriptor.extensions;
}
extensionDict[ext] = extDescriptor;
return subExtensions;
}));
return extensions;
};
await traverseExtensions(templatesDirectory);
const findFilesRecursiveSync = (baseDir, criteriaFn = () => true) => {
const subPaths = fs__default.readdirSync(baseDir);
const files = subPaths.map((relativePath) => {
const fullPath = path.resolve(baseDir, relativePath);
return fs__default.lstatSync(fullPath).isDirectory()
? [...findFilesRecursiveSync(fullPath, criteriaFn)]
: criteriaFn(fullPath)
? [fullPath]
: [];
});
return files.flat();
};
// @ts-expect-error We don't have types for this probably add .d.ts file
function mergePackageJson(targetPackageJsonPath, secondPackageJsonPath, isDev) {
const existsTarget = fs__default.existsSync(targetPackageJsonPath);
const existsSecond = fs__default.existsSync(secondPackageJsonPath);
if (!existsTarget && !existsSecond) {
return;
}
const targetPackageJson = existsTarget ? fs__default.readFileSync(targetPackageJsonPath, "utf8") : '{}';
const secondPackageJson = existsSecond ? fs__default.readFileSync(secondPackageJsonPath, "utf8") : '{}';
const mergedPkgStr = mergeJsonStr.default(targetPackageJson, secondPackageJson);
fs__default.writeFileSync(targetPackageJsonPath, mergedPkgStr, "utf8");
if (isDev) {
const devStr = `TODO: write relevant information for the contributor`;
fs__default.writeFileSync(`${targetPackageJsonPath}.dev`, devStr, "utf8");
}
}
const { mkdir, link } = promises;
/**
* The goal is that this function has the same API as ncp, so they can be used
* interchangeably.
*
* - clobber not implemented
*/
const linkRecursive = async (source, destination, options) => {
const passesFilter = options?.filter === undefined
? true // no filter
: typeof options.filter === 'function'
? options.filter(source) // filter is function
: options.filter.test(source); // filter is regex
if (!passesFilter) {
return;
}
if (lstatSync(source).isDirectory()) {
const subPaths = readdirSync(source);
await Promise.all(subPaths.map(async (subPath) => {
const sourceSubpath = path.join(source, subPath);
const isSubPathAFolder = lstatSync(sourceSubpath).isDirectory();
const destSubPath = path.join(destination, subPath);
const existsDestSubPath = existsSync(destSubPath);
if (isSubPathAFolder && !existsDestSubPath) {
await mkdir(destSubPath);
}
await linkRecursive(sourceSubpath, destSubPath, options);
}));
return;
}
return link(source, destination);
};
const copy = promisify(ncp);
let copyOrLink = copy;
const expandExtensions = (options) => {
const expandedExtensions = options.extensions
.map((extension) => extensionDict[extension])
.map((extDescriptor) => [extDescriptor.extends, extDescriptor.value].filter(isDefined))
.flat()
// this reduce just removes duplications
.reduce((exts, ext) => (exts.includes(ext) ? exts : [...exts, ext]), []);
return expandedExtensions;
};
const isTemplateRegex = /([^\/\\]*?)\.template\./;
const isPackageJsonRegex = /package\.json/;
const isPnpmLockRegex = /pnpm-lock\.yaml/;
const isNextGeneratedRegex = /packages\/nextjs\/generated/;
const isConfigRegex = /([^\/\\]*?)\\config\.json/;
const isArgsRegex = /([^\/\\]*?)\.args\./;
const isExtensionFolderRegex = /extensions$/;
const isPackagesFolderRegex = /packages$/;
const copyBaseFiles = async ({ dev: isDev }, basePath, targetDir) => {
await copyOrLink(basePath, targetDir, {
clobber: false,
filter: (fileName) => {
const isTemplate = isTemplateRegex.test(fileName);
const isPackageJson = isPackageJsonRegex.test(fileName);
const isPnpmLock = isPnpmLockRegex.test(fileName);
const isNextGenerated = isNextGeneratedRegex.test(fileName);
const skipAlways = isTemplate || isPackageJson;
const skipDevOnly = isPnpmLock || isNextGenerated;
const shouldSkip = skipAlways || (isDev && skipDevOnly);
return !shouldSkip;
},
});
const basePackageJsonPaths = findFilesRecursiveSync(basePath, path => isPackageJsonRegex.test(path));
basePackageJsonPaths.forEach(packageJsonPath => {
const partialPath = packageJsonPath.split(basePath)[1];
mergePackageJson(path.join(targetDir, partialPath), path.join(basePath, partialPath), isDev);
});
if (isDev) {
const basePnpmLockPaths = findFilesRecursiveSync(basePath, path => isPnpmLockRegex.test(path));
basePnpmLockPaths.forEach(pnpmLockPath => {
const partialPath = pnpmLockPath.split(basePath)[1];
copy(path.join(basePath, partialPath), path.join(targetDir, partialPath));
});
const nextGeneratedPaths = findFilesRecursiveSync(basePath, path => isNextGeneratedRegex.test(path));
nextGeneratedPaths.forEach(nextGeneratedPath => {
const partialPath = nextGeneratedPath.split(basePath)[1];
copy(path.join(basePath, partialPath), path.join(targetDir, partialPath));
});
}
};
const copyExtensionsFiles = async ({ extensions, dev: isDev }, targetDir) => {
await Promise.all(extensions.map(async (extension) => {
const extensionPath = extensionDict[extension].path;
// copy (or link if dev) root files
await copyOrLink(extensionPath, path.join(targetDir), {
clobber: false,
filter: (path) => {
const isConfig = isConfigRegex.test(path);
const isArgs = isArgsRegex.test(path);
const isExtensionFolder = isExtensionFolderRegex.test(path) && fs__default.lstatSync(path).isDirectory();
const isPackagesFolder = isPackagesFolderRegex.test(path) && fs__default.lstatSync(path).isDirectory();
const isTemplate = isTemplateRegex.test(path);
// PR NOTE: this wasn't needed before because ncp had the clobber: false
const isPackageJson = isPackageJsonRegex.test(path);
const shouldSkip = isConfig ||
isArgs ||
isTemplate ||
isPackageJson ||
isExtensionFolder ||
isPackagesFolder;
return !shouldSkip;
},
});
// merge root package.json
mergePackageJson(path.join(targetDir, "package.json"), path.join(extensionPath, "package.json"), isDev);
const extensionPackagesPath = path.join(extensionPath, "packages");
const hasPackages = fs__default.existsSync(extensionPackagesPath);
if (hasPackages) {
// copy extension packages files
await copyOrLink(extensionPackagesPath, path.join(targetDir, "packages"), {
clobber: false,
filter: (path) => {
const isArgs = isArgsRegex.test(path);
const isTemplate = isTemplateRegex.test(path);
const isPackageJson = isPackageJsonRegex.test(path);
const shouldSkip = isArgs || isTemplate || isPackageJson;
return !shouldSkip;
},
});
// copy each package's package.json
const extensionPackages = fs__default.readdirSync(extensionPackagesPath);
extensionPackages.forEach((packageName) => {
mergePackageJson(path.join(targetDir, "packages", packageName, "package.json"), path.join(extensionPath, "packages", packageName, "package.json"), isDev);
});
}
}));
};
const processTemplatedFiles = async ({ extensions, dev: isDev }, basePath, targetDir) => {
const baseTemplatedFileDescriptors = findFilesRecursiveSync(basePath, (path) => isTemplateRegex.test(path)).map((baseTemplatePath) => ({
path: baseTemplatePath,
fileUrl: url.pathToFileURL(baseTemplatePath).href,
relativePath: baseTemplatePath.split(basePath)[1],
source: "base",
}));
const extensionsTemplatedFileDescriptors = extensions
.map((ext) => findFilesRecursiveSync(extensionDict[ext].path, (filePath) => isTemplateRegex.test(filePath)).map((extensionTemplatePath) => ({
path: extensionTemplatePath,
fileUrl: url.pathToFileURL(extensionTemplatePath).href,
relativePath: extensionTemplatePath.split(extensionDict[ext].path)[1],
source: `extension ${extensionDict[ext].name}`,
})))
.flat();
await Promise.all([
...baseTemplatedFileDescriptors,
...extensionsTemplatedFileDescriptors,
].map(async (templateFileDescriptor) => {
const templateTargetName = templateFileDescriptor.path.match(isTemplateRegex)?.[1];
const argsPath = templateFileDescriptor.relativePath.replace(isTemplateRegex, `${templateTargetName}.args.`);
const argsFileUrls = extensions
.map((extension) => {
const argsFilePath = path.join(extensionDict[extension].path, argsPath);
const fileExists = fs__default.existsSync(argsFilePath);
if (!fileExists) {
return [];
}
return url.pathToFileURL(argsFilePath).href;
})
.flat();
const args = await Promise.all(argsFileUrls.map(async (argsFileUrl) => await import(argsFileUrl)));
const template = (await import(templateFileDescriptor.fileUrl)).default;
if (!template) {
throw new Error(`Template ${templateTargetName} from ${templateFileDescriptor.source} doesn't have a default export`);
}
if (typeof template !== "function") {
throw new Error(`Template ${templateTargetName} from ${templateFileDescriptor.source} is not exporting a function by default`);
}
const freshArgs = Object.fromEntries(Object.keys(args[0] ?? {}).map((key) => [
key, // INFO: key for the freshArgs object
[], // INFO: initial value for the freshArgs object
]));
const combinedArgs = args.reduce((accumulated, arg) => {
Object.entries(arg).map(([key, value]) => {
accumulated[key].push(value);
});
return accumulated;
}, freshArgs);
// TODO test: if first arg file found only uses 1 name, I think the rest are not used?
const output = template(combinedArgs);
const targetPath = path.join(targetDir, templateFileDescriptor.relativePath.split(templateTargetName)[0], templateTargetName);
fs__default.writeFileSync(targetPath, output);
if (isDev) {
const hasCombinedArgs = Object.keys(combinedArgs).length > 0;
const hasArgsPaths = argsFileUrls.length > 0;
const devOutput = `--- TEMPLATE FILE
templates/${templateFileDescriptor.source}${templateFileDescriptor.relativePath}
--- ARGS FILES
${hasArgsPaths
? argsFileUrls.map(url => `\t- ${path.join('templates', url.split('templates')[1])}`).join('\n')
: '(no args files writing to the template)'}
--- RESULTING ARGS
${hasCombinedArgs
? Object.entries(combinedArgs)
.map(([argName, argValue]) => `\t- ${argName}:\t[${argValue.join(',')}]`)
// TODO improvement: figure out how to add the values added by each args file
.join('\n')
: '(no args sent for the template)'}
`;
fs__default.writeFileSync(`${targetPath}.dev`, devOutput);
}
}));
};
async function copyTemplateFiles(options, templateDir, targetDir) {
copyOrLink = options.dev ? linkRecursive : copy;
const basePath = path.join(templateDir, baseDir);
// 1. Copy base template to target directory
await copyBaseFiles(options, basePath, targetDir);
// 2. Add "parent" extensions (set via config.json#extend field)
const expandedExtension = expandExtensions(options);
options.extensions = expandedExtension;
// 3. Copy extensions folders
await copyExtensionsFiles(options, targetDir);
// 4. Process templated files and generate output
await processTemplatedFiles(options, basePath, targetDir);
// 5. Initialize git repo to avoid husky error
await execa("git", ["init"], { cwd: targetDir });
await execa("git", ["checkout", "-b", "main"], { cwd: targetDir });
}
async function createProjectDirectory(projectName) {
try {
const result = await execa("mkdir", [projectName]);
if (result.failed) {
throw new Error("There was a problem running the mkdir command");
}
}
catch (error) {
throw new Error("Failed to create directory", { cause: error });
}
return true;
}
function installPackages(targetDir) {
return new Promise((resolve, reject) => {
const install = spawn('pnpm', ['install', '--reporter=verbose'], {
cwd: targetDir,
stdio: 'inherit',
});
install.on('close', (code) => {
if (code !== 0) {
reject(new Error(`pnpm install failed with exit code ${code}`));
}
else {
resolve(true);
}
});
install.on('error', (error) => {
reject(new Error('Failed to start pnpm install', { cause: error }));
});
});
}
// Checkout the latest release tag in a git submodule
async function checkoutLatestTag(submodulePath) {
try {
const { stdout } = await execa("git", ["tag", "-l", "--sort=-v:refname"], {
cwd: submodulePath,
});
const tagLines = stdout.split("\n");
if (tagLines.length > 0) {
const latestTag = tagLines[0];
await execa("git", ["-C", `${submodulePath}`, "checkout", latestTag]);
}
else {
throw new Error(`No tags found in submodule at ${submodulePath}`);
}
}
catch (error) {
console.error("Error checking out latest tag:", error);
throw error;
}
}
async function createFirstGitCommit(targetDir, options) {
try {
// TODO: Move the logic for adding submodules to tempaltes
if (options.extensions?.includes("foundry")) {
const foundryWorkSpacePath = path.resolve(targetDir, "packages", "foundry");
await execa("git", [
"submodule",
"add",
"https://github.com/foundry-rs/forge-std",
"lib/forge-std",
], {
cwd: foundryWorkSpacePath,
});
await execa("git", [
"submodule",
"add",
"https://github.com/OpenZeppelin/openzeppelin-contracts",
"lib/openzeppelin-contracts",
], {
cwd: foundryWorkSpacePath,
});
await execa("git", [
"submodule",
"add",
"https://github.com/gnsps/solidity-bytes-utils",
"lib/solidity-bytes-utils",
], {
cwd: foundryWorkSpacePath,
});
await execa("git", ["submodule", "update", "--init", "--recursive"], {
cwd: foundryWorkSpacePath,
});
await checkoutLatestTag(path.resolve(foundryWorkSpacePath, "lib", "forge-std"));
await checkoutLatestTag(path.resolve(foundryWorkSpacePath, "lib", "openzeppelin-contracts"));
}
await execa("git", ["add", "-A"], { cwd: targetDir });
await execa("git", ["commit", "-m", "Initial commit with ๐งช Xchange Create", "--no-verify"], { cwd: targetDir });
// Update the submodule, since we have checked out the latest tag in the previous step of foundry
if (options.extensions?.includes("foundry")) {
await execa("git", ["submodule", "update", "--init", "--recursive"], {
cwd: path.resolve(targetDir, "packages", "foundry"),
});
}
}
catch (e) {
// cast error as ExecaError to get stderr
throw new Error("Failed to initialize git repository", {
cause: e?.stderr ?? e,
});
}
}
async function prettierFormat(targetDir) {
return new Promise((resolve, reject) => {
const install = spawn('pnpm', ['format'], {
cwd: targetDir,
stdio: 'inherit',
});
install.on('close', (code) => {
if (code !== 0) {
reject(new Error(`pnpm install failed with exit code ${code}`));
}
else {
resolve(true);
}
});
install.on('error', (error) => {
reject(new Error('Failed to start pnpm install', { cause: error }));
});
});
// try {
// const result = await execa("pnpm run", ["format"], { cwd: targetDir });
// if (result.failed) {
// throw new Error("There was a problem running the format command");
// }
// } catch (error) {
// throw new Error("Failed to create directory", { cause: error });
// }
// return true;
}
async function renderOutroMessage(options) {
let message = `
\n
${chalk.bold.green("Congratulations!")} Your project has been created! ๐
${chalk.bold("Next steps:")}
${chalk.dim("cd")} ${options.project}
`;
if (options.extensions.includes("hardhat") ||
options.extensions.includes("foundry")) {
message += `
\t${chalk.bold("Start the local development node")}
\t${chalk.dim("pnpm run")} chain
`;
if (options.extensions.includes("foundry")) {
try {
await execa("foundryup", ["-h"]);
}
catch (error) {
message += `
\t${chalk.bold.yellow("(NOTE: Foundryup is not installed in your system)")}
\t${chalk.dim("Checkout: https://getfoundry.sh")}
`;
}
}
message += `
\t${chalk.bold("In a new terminal window, get your selected contract on chain")}
\t${chalk.dim("pnpm run")} generate ${chalk.dim("// generates a new wallet")}
\t${chalk.dim("pnpm run")} account ${chalk.dim("// ensure your wallet is funded")}
`;
}
message += `
\t${chalk.bold("In a new terminal window, start the frontend")}
\t${chalk.dim("pnpm run")} start
`;
message += `
${chalk.bold.green("Thanks for using ๐งช Xchange Create, Trust No One. Trust Code. Long Live DeFi!")}
`;
console.log(message);
}
function getContractName(contractType) {
switch (contractType) {
case "standard-token":
return "StandardToken";
case "tax-token":
return "StandardToken"; // Assuming tax-token also uses StandardToken
case "deflationary-token":
return "DeflationaryToken";
case "test-erc20":
return "MockERC20";
case "my-custom-contract":
return undefined; // Return undefined for custom contracts
default:
return undefined;
}
}
async function createProject(options) {
console.log(`\n`);
const currentFileUrl = import.meta.url;
const templateDirectory = path.resolve(decodeURI(fileURLToPath(currentFileUrl)), "../../templates");
const targetDirectory = path.resolve(process.cwd(), options.project);
const envFilePath = path.join(targetDirectory, "packages", "hardhat", ".env");
const tasks = new Listr([
{
title: `๐ Create project directory ${targetDirectory}`,
task: () => createProjectDirectory(options.project),
},
{
title: `๐งช Creating a new Xchange project in ${chalk.green.bold(options.project)}`,
task: () => copyTemplateFiles(options, templateDirectory, targetDirectory),
},
{
title: "๐ Writing options to .env file",
task: () => {
const existingEnvConfig = fs.existsSync(envFilePath)
? parse(fs.readFileSync(envFilePath, "utf8"))
: {};
const newEnvConfig = {
...existingEnvConfig,
TOKEN_NAME: options.project,
TOKEN_SYMBOL: options.ticker,
TOKEN_SUPPLY: options.supply.toString(),
TOKEN_SUPPLY_PAIRED: options.supply,
DEPLOYER_PRIVATE_KEY: "",
CONTRACT_NAME: getContractName(options.contractType),
LOAN_TERM_CONTRACT_ADDRESS: "0xd95f799276A8373F7F234A7F211DE9E3a0ae6639",
LOAN_AMOUNT: 0.5,
INITIAL_PAYMENT_DUE: 0.11
};
fs.writeFileSync(envFilePath, stringify(newEnvConfig));
// Rename the contract file to the TOKEN_NAME in the new project directory
const contractFilePath = path.join(targetDirectory, "packages", "hardhat", "contracts", `${getContractName(options.contractType)}.sol`);
const newContractFilePath = path.join(path.dirname(contractFilePath), `${options.project}.sol`);
// Read the contents of the contract file
const contractContent = fs.readFileSync(contractFilePath, "utf8");
// Replace the contract name in the file contents
const updatedContractContent = contractContent.replace(new RegExp(`contract ${getContractName(options.contractType)}`, "g"), `contract ${options.project}`);
// Write the updated contents to the new contract file
fs.writeFileSync(newContractFilePath, updatedContractContent);
// Remove the original contract file
fs.unlinkSync(contractFilePath);
// Update the CONTRACT_NAMES export in the new project directory
const constantsFilePath = path.join(targetDirectory, "packages", "hardhat", "utils", "constants.ts");
const constantsContent = fs.readFileSync(constantsFilePath, "utf8");
const updatedConstantsContent = constantsContent.replace(/export const CONTRACT_NAMES = {[^}]*}/, `export const CONTRACT_NAMES = {
StandardToken: "StandardToken",
DeflationaryToken: "DeflationaryToken",
MockERC20: "MockERC20",
${options.project}: "${options.project}",
};`);
fs.writeFileSync(constantsFilePath, updatedConstantsContent);
},
},
{
title: `๐ฆ Installing dependencies with pnpm, this could take a while`,
task: () => installPackages(targetDirectory),
skip: () => {
if (!options.install) {
return "Manually skipped";
}
},
},
{
title: `๐ช${" "}Formatting files with prettier`,
task: () => prettierFormat(targetDirectory),
skip: () => {
if (!options.install) {
return "Skipping because prettier install was skipped";
}
},
},
{
title: `๐ก${" "}Initializing Git repository ${options.extensions?.includes("foundry") ? "and submodules" : ""}`,
task: () => createFirstGitCommit(targetDirectory, options),
},
]);
try {
await tasks.run();
renderOutroMessage(options);
}
catch (error) {
console.log("%s Error occurred", chalk.red.bold("ERROR"), error);
console.log("%s Exiting...", chalk.red.bold("Uh oh! ๐ Sorry about that!"));
}
}
function parseArgumentsIntoOptions(rawArgs) {
const args = arg({
"--project": String,
"-p": "--project",
"--install": Boolean,
"-i": "--install",
"--skip-install": Boolean,
"--skip": "--skip-install",
"-s": "--skip-install",
"--dev": Boolean,
"--ticker": String,
"-t": "--ticker",
"--supply": Number,
"-u": "--supply",
"--contract-type": String,
"-c": "--contract-type",
"--extensions": [String],
"-e": "--extensions",
"--help": Boolean,
"-h": "--help",
"--quote": Boolean,
"-q": "--quote",
"--usd": Boolean,
"--network": String,
"-n": "--network",
}, {
argv: rawArgs.slice(2).map((a) => a.toLowerCase()),
});
const install = args["--install"] ?? null;
const skipInstall = args["--skip-install"] ?? null;
const hasInstallRelatedFlag = install || skipInstall;
const dev = args["--dev"] ?? false;
const project = args["--project"] ?? null;
const ticker = args["--ticker"] ?? null;
const supply = args["--supply"] ?? null;
const contractType = args["--contract-type"] ?? null;
const extensions = args["--extensions"] ?? null;
const help = args["--help"] ?? false;
const usd = args["--usd"] ?? false;
const quote = args["--quote"] ?? false;
const network = args["--network"] ?? null;
return {
project,
install: hasInstallRelatedFlag ? install || !skipInstall : null,
ticker,
supply,
contractType,
dev,
extensions,
help,
quote,
network,
usd
};
}
const config = {
questions: [
typedQuestion({
type: "single-select",
name: "solidity-framework",
message: "What solidity framework do you want to use?",
extensions: ["hardhat", "foundry", null],
default: "hardhat",
}),
typedContractTypeQuestion({
type: "single-select",
name: "contract-type",
message: "What kind of token contract do you want to use?",
contractTypes: ["standard-token", "tax-token", "deflationary-token", "test-erc20", "my-custom-contract"],
default: "standard-token",
}),
],
};
// default values for unspecified args
const defaultOptions = {
project: "my-project",
ticker: "TICKER",
supply: 100000000,
install: true,
dev: false,
extensions: [],
contractType: "standard-token",
quote: false,
network: "sepolia",
usd: false,
};
const invalidQuestionNames = ["project", "install"];
const nullExtensionChoice = {
name: 'None',
value: null
};
async function promptForMissingOptions(options, questionType) {
const questions = [];
if (questionType === "create") {
questions.push({
type: "input",
name: "project",
message: "Your project name:",
default: defaultOptions.project,
validate: (value) => value.length > 0,
});
questions.push({
type: "input",
name: "ticker",
message: "What is your TICKER?",
default: defaultOptions.ticker,
validate: (value) => value.length > 0,
});
questions.push({
type: "number",
name: "supply",
message: "What is your token supply?",
default: defaultOptions.supply,
validate: (value) => value > 0,
});
const recurringAddFollowUps = (extensions, relatedQuestion) => {
extensions.filter(extensionWithSubextensions).forEach((ext) => {
const nestedExtensions = ext.extensions.map((nestedExt) => extensionDict[nestedExt]);
questions.push({
// INFO: assuming nested extensions are all optional. To change this,
// update ExtensionDescriptor adding type, and update code here.
type: "checkbox",
name: `${ext.value}-extensions`,
message: `Select optional extensions for ${ext.name}`,
choices: nestedExtensions,
when: (answers) => {
const relatedResponse = answers[relatedQuestion];
const wasMultiselectResponse = Array.isArray(relatedResponse);
return wasMultiselectResponse
? relatedResponse.includes(ext.value)
: relatedResponse === ext.value;
},
});
recurringAddFollowUps(nestedExtensions, `${ext.value}-extensions`);
});
};
config.questions.forEach((question) => {
if (invalidQuestionNames.includes(question.name)) {
throw new Error(`The name of the question can't be "${question.name}". The invalid names are: ${invalidQuestionNames
.map((w) => `"${w}"`)
.join(", ")}`);
}
if (question.type === "single-select" && question.name === "contract-type") {
const contractTypes = question.contractTypes;
questions.push({
type: "list",
name: question.name,
message: question.message,
choices: contractTypes,
});
}
else {
const extensions = question.extensions
.filter(isExtension)
.map((ext) => extensionDict[ext])
.filter(isDefined);
const hasNoneOption = question.extensions.includes(null);
questions.push({
type: question.type === "multi-select" ? "checkbox" : "list",
name: question.name,
message: question.message,
choices: hasNoneOption ? [...extensions, nullExtensionChoice] : extensions,
});
recurringAddFollowUps(extensions, question.name);
}
});
questions.push({
type: "confirm",
name: "install",
message: "Install packages?",
default: defaultOptions.install,
});
}
if (questionType === "quote") {
// Prompt only for the options required for the quote
questions.push({
type: "input",
name: "network",
message: "Enter the network:",
validate: (value) => ["base", "polygon", "bsc", "eth", "arbitrum", "optimism"].includes(value),
when: () => !options.network,
});
questions.push({
type: "input",
name: "contractType",
message: "Enter the contract name:",
validate: (value) => value.trim() !== "",
when: () => !options.contractType,
});
}
const answers = await inquirer.prompt(questions);
const mergedOptions = {
project: options.project ?? answers.project,
ticker: options.ticker ?? answers.ticker,
supply: options.supply ?? answers.supply,
install: options.install ?? answers.install,
contractType: options.contractType ?? defaultOptions.contractType,
dev: options.dev ?? defaultOptions.dev,
quote: options?.quote ?? defaultOptions.quote,
network: options?.network ?? defaultOptions.network,
usd: options?.usd ?? defaultOptions.usd,
extensions: [],
};
config.questions.forEach((question) => {
const { name } = question;
if (question.name === "solidity-framework") {
const choice = [answers[name]].flat().filter(isDefined);
mergedOptions.extensions.push(...choice);
}
else if (question.type === "single-select" && question.name === "contract-type") {
mergedOptions.contractType = answers[name];
}
});
const recurringAddNestedExtensions = (baseExtensions) => {
baseExtensions.forEach((extValue) => {
const nestedExtKey = `${extValue}-extensions`;
const nestedExtensions = answers[nestedExtKey];
if (nestedExtensions) {
mergedOptions.extensions.push(...nestedExtensions);
recurringAddNestedExtensions(nestedExtensions);
}
});
};
recurringAddNestedExtensions(mergedOptions.extensions);
return mergedOptions;
}
const TITLE_TEXT = `
${chalk.bold.green(" $$\\ $$\\ $$\\ ")}
${chalk.bold.green(" $$ | $$ | $$ | ")}
${chalk.bold.green(" \\$$\\ $$ | $$$$$$$\\ $$$$$$$\\ $$$$$$\\ $$$$$$$\\ $$$$$$\\ $$$$$$\\ ")}
${chalk.bold.green(" \\$$$$ / $$ _____|$$ __$$\\ \\____$$\\ $$ __$$\\ $$ __$$\\ $$ __$$\\ ")}
${chalk.bold.green(" $$ $$< $$ / $$ | $$ | $$$$$$$ |$$ | $$ |$$ / $$ |$$$$$$$$ |")}
${chalk.bold.green(" $$ /\\$$\\ $$ | $$ | $$ |$$ __$$ |$$ | $$ |$$ | $$ |$$ ____|")}
${chalk.bold.green(" $$ / $$ |\\$$$$$$$\\ $$ | $$ |\\$$$$$$$ |$$ | $$ |\\$$$$$$$ |\\$$$$$$$\\ ")}
${chalk.bold.green(" \\__| \\__| \\_______|\\__| \\__| \\_______|\\__| \\__| \\____$$ | \\_______|")}
${chalk.bold.green(" $$\\ $$ | ")}
${chalk.bold.green(" \\$$$$$$ | ")}
${chalk.bold.green(" \\______/ ")}
${chalk.dim("๐งช Xchange Create will scaffold and entire project allowing you to deploy your project to Xchange in seconds.")}
${chalk.dim(" ")}
${chalk.dim("Trust No One. Trust Code. Long Live DeFi!")}
`;
function renderIntroMessage() {
console.log(TITLE_TEXT);
}
const chainMap = {
base: base,
polygon: polygon,
bsc: bsc,
mainnet: mainnet,
arbitrum: arbitrum,
optimism: optimism,
// testnets
baseSepolia: baseSepolia,
sepolia: sepolia,
};
async function getGasPrice(network) {
const chain = chainMap[network];
const client = createPublicClient({
chain,
transport: http(),
});
const gasPrice = await client.getGasPrice();
const gasPriceInGwei = Number(gasPrice) / 10 ** 9;
return gasPriceInGwei;
}
const ammPoolsPerChain = {
base: ["Xchange", "Uniswap", "Sushiswap"],
polygon: ["Xchange", "Uniswap", "Sushiswap"],
bsc: ["Xchange", "Uniswap", "Sushiswap"],
mainnet: ["Xchange", "Uniswap", "Sushiswap"],
arbitrum: ["Xchange", "Uniswap", "Sushiswap"],
optimism: ["Xchange", "Uniswap", "Sushiswap"],
// testnets
baseSepolia: ["Xchange", "Uniswap", "Sushiswap"],
sepolia: ["Xchange", "Uniswap", "Sushiswap"],
};
const COINGECKO_API_URL = "https://api.coingecko.com/api/v3";
const nativeTokenIds = {
mainnet: "ethereum",
bsc: "binancecoin",
polygon: "matic-network",
arbitrum: "ethereum", // Arbitrum uses ETH as its native token
optimism: "ethereum", // Optimism uses ETH as its native token
base: "ethereum", // Base uses ETH as its native token
};
async function fetchNativeTokenPrice(network) {
const tokenId = nativeTokenIds[network];
if (!tokenId) {
throw new Error(`Unsupported network: ${network}`);
}
try {
const response = await axios.get(`${COINGECKO_API_URL}/simple/price`, {
params: {
ids: tokenId,
vs_currencies: "usd",
},
});
const data = response.data;
const price = data[tokenId]?.usd;
if (!price) {
throw new Error(`Failed to fetch price for ${tokenId}`);
}
return price;
}
catch (error) {
console.error("Error fetching native token price:", error);
throw error;
}
}
async function displayQuote(options) {
const { network, contractType, usd } = options;
if (!isValidNetwork(network)) {
console.error(`Error: Unsupported network "${network}". Supported networks are: ${Object.keys(ammPoolsPerChain).join(", ")}.`);
return;
}
if (!isValidContractType(contractType)) {
console.error(`Error: Unsupported contract type "${contractType}". Supported contract types are: standard-token, tax-token, deflationary-token, test-erc20, my-custom-contract.`);
return;
}
const gasPrice = await getGasPrice(network);
const availablePools = ammPoolsPerChain[network];
const nativeTokenPrice = usd ? await fetchNativeTokenPrice(network) : 0;
const quoteData = availablePools.map((pool) => ({
pool,
deploymentCost: calculateDeploymentCost(gasPrice),
pairCreationCost: calculatePairCreationCost(gasPrice),
lendingPoolApprovalCost: pool === "Xchange" ? calculateLendingPoolApprovalCost(gasPrice) : "N/A",
loanInitiationCost: pool === "Xchange" ? calculateLoanInitiationCost(gasPrice) : "N/A",
deploymentCostUsd: usd ? calculateDeploymentCost(gasPrice) * nativeTokenPrice : undefined,
pairCreationCostUsd: usd ? calculatePairCreationCost(gasPrice) * nativeTokenPrice : undefined,
lendingPoolApprovalCostUsd: pool === "Xchange" && usd ? calculateLendingPoolApprovalCost(gasPrice) * nativeTokenPrice : "N/A",
loanInitiationCostUsd: pool === "Xchange" && usd ? calculateLoanInitiationCost(gasPrice) * nativeTokenPrice : "N/A",
}));
const table = new Table({
title: "Cost To Deploy Contract + AMM Pool",
columns: [
{ name: "Network", alignment: "left" },
{ name: "Pool", alignment: "left" },
{ name: "Deployment Cost", alignment: "right" },
{ name: "Pair Creation Cost", alignment: "right" },
{ name: "Lending Pool Approval Cost", alignment: "right" },
{ name: "Loan Initiation Cost", alignment: "right" },
{ name: "Gas Price (Gwei)", alignment: "right" },
],
});
quoteData.forEach((data) => {
const row = {
Network: network,
Pool: data.pool,
"Deployment Cost": usd
? formatPrice(data.deploymentCostUsd, true, network)
: formatPrice(data.deploymentCost, false, network),
"Pair Creation Cost": usd
? formatPrice(data.pairCreationCostUsd, true, network)
: formatPrice(data.pairCreationCost, false, network),
"Lending Pool Approval Cost": usd
? formatPrice(data.lendingPoolApprovalCostUsd, true, network)
: formatPrice(data.lendingPoolApprovalCost, false, network),
"Loan Initiation Cost": usd
? formatPrice(data.loanInitiationCostUsd, true, network)
: formatPrice(data.loanInitiationCost, false, network),
"Gas Price (Gwei)": gasPrice,
};
table.addRow(row);
});
table.printTable();
}
function isValidNetwork(network) {
return Object.keys(ammPoolsPerChain).includes(network);
}
function isValidContractType(contractType) {
const supportedContractTypes = ["standard-token", "tax-token", "deflationary-token", "my-custom-contract"];
return supportedContractTypes.includes(contractType);
}
// Placeholder functions for calculating costs
function calculateDeploymentCost(gasPrice, contractType) {
// Calculate the deployment cost based on the gas price and contract name
// You'll need to replace this with the actual calculation logic
return gasPrice * 0.05;
}
function calculatePairCreationCost(gasPrice, contractType) {
// Calculate the pair creation cost based on the gas price and contract name
// You'll need to replace this with the actual calculation logic
return gasPrice * 0.002;
}
function calculateLendingPoolApprovalCost(gasPrice, contractType) {
// Calculate the lending pool approval cost based on the gas price and contract name
// You'll need to replace this with the actual calculation logic
return gasPrice * 0.001;
}
function calculateLoanInitiationCost(gasPrice, contractType) {
// Calculate the loan initiation cost based on the gas price and contract name
// You'll need to replace this with the actual calculation logic
return gasPrice * 0.01;
}
function formatNativePrice(price, network) {
return `${price.toFixed(4)} ${getNativeTokenSymbol(network)}`;
}
function formatUsdPrice(price) {
return `$${price.toFixed(2)} USD`;
}
function formatPrice(price, isUsd, network) {
if (price === "N/A" || price === undefined) {
return "N/A";
}
return isUsd ? formatUsdPrice(price) : formatNativePrice(price, network);
}
function getNativeTokenSymbol(network) {
switch (network) {
case "mainnet":
return "ETH";
case "bsc":
return "BNB";
case "polygon":
return "MATIC";
case "arbitrum":
return "ETH";
case "optimism":
return "ETH";
case "base":
return "ETH";
default:
return "";
}
}
async function cli(args) {
const rawOptions = parseArgumentsIntoOptions(args);
if (rawOptions.help) {
displayHelp();
return;
}
if (rawOptions.quote) {
const options = await promptForMissingOptions(rawOptions, "quote");
await displayQuote(options);
return;
}
renderIntroMessage();
const options = await promptForMissingOptions(rawOptions, 'create');
await createProject(options);
}
function displayHelp() {
console.log(`
Usage: xc [options]
Options:
--project, -p <name> Specify the project name
--ticker, -t <symbol> Specify the ticker symbol
--supply, -u <number> Specify the token supply
--contract-type, -c <type> Specify the contract type
--extensions, -e <list> Specify the extensions (comma-separated)
--install, -i Install dependencies
--skip-install, -s Skip dependency installation
--dev Enable development mode
--help, -h Display help information
--quote, -q Display deployment cost quote
--usd Display cost in USD
--network, -n <network> Specify the network
Examples:
xc --project my-project --ticker MYTICKER --supply 1000000 --contract-type standard-token --extensions extension1,extension2 --install --dev
xc -p my-project -t MYTICKER -u 1000000 -c standard-token -e extension1,extension2 -i --dev
xc --quote --network eth --contract-type standard-token --usd
`);
}
export { cli };
//# sourceMappingURL=cli.js.map