From 70a2cace0b34b3e1171bb8dfbb0ad18542f804f8 Mon Sep 17 00:00:00 2001 From: Alan Soares Date: Wed, 8 Nov 2023 15:09:27 +1300 Subject: [PATCH 1/2] feat: introduce @axelarjs/evm cli --- packages/evm/bin/cli.sh | 45 ++++ packages/evm/package.json | 2 + packages/evm/scripts/codegen.ts | 356 +++++++++++++++++++------------- 3 files changed, 260 insertions(+), 143 deletions(-) create mode 100755 packages/evm/bin/cli.sh mode change 100755 => 100644 packages/evm/scripts/codegen.ts diff --git a/packages/evm/bin/cli.sh b/packages/evm/bin/cli.sh new file mode 100755 index 000000000..e077e13fe --- /dev/null +++ b/packages/evm/bin/cli.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +# commands: +# codegen: generate code from abi + +COMMAND=$1 + +# comma separated list of valid commands +VALID_COMMANDS="codegen" + +get_npx_compatible_command(){ + # check if npx is installed + if command -v npx &> /dev/null + then + echo "npx" + # check if pnpx is installed + elif command -v pnpx &> /dev/null + then + echo "pnpx" + # check if bunx is installed + elif command -v bunx &> /dev/null + then + echo "bunx" + else + echo "npx, pnpx or bunx is required to run this script" + exit 1 + fi +} + + +# get the npx compatible command +NPX_COMMAND=$(get_npx_compatible_command) + +case $COMMAND in + codegen) + echo "using '$NPX_COMMAND' to run codegen" + $NPX_COMMAND tsx ./scripts/codegen.ts "$@" + ;; + *) + echo "unknown command received: '$COMMAND'" + echo "valid commands:" + # split string into array and print each element on a new line with a - prefix + echo "$VALID_COMMANDS" | tr ',' '\n' | sed 's/^/ * /' + ;; +esac \ No newline at end of file diff --git a/packages/evm/package.json b/packages/evm/package.json index 564bc9436..a21caf08b 100644 --- a/packages/evm/package.json +++ b/packages/evm/package.json @@ -12,11 +12,13 @@ "files": [ "./build", "./contracts", + "./bin", "./index.js", "./index.d.ts", "./clients.js", "./clients.d.ts" ], + "bin": "./bin/cli.sh", "exports": { ".": { "import": "./build/module/index.js", diff --git a/packages/evm/scripts/codegen.ts b/packages/evm/scripts/codegen.ts old mode 100755 new mode 100644 index a73f66108..116b5c619 --- a/packages/evm/scripts/codegen.ts +++ b/packages/evm/scripts/codegen.ts @@ -5,21 +5,11 @@ import { capitalize } from "@axelarjs/utils/string"; import fs from "fs/promises"; import path from "path"; import prettier from "prettier"; -import { $ } from "zx"; +import { $, argv, chalk, glob, spinner } from "zx"; $.verbose = false; -const kebabToPascalCase = convertCase("kebab-case", "PascalCase"); - -const kebabToConstantCase = convertCase("kebab-case", "CONSTANT_CASE"); - -const PACKAGE_NAME = "@axelar-network/interchain-token-service"; - -const CONTRACT_FOLDERS = [ - "interchain-token-service", - "interchain-token", - "token-manager", -]; +const pascalToKebabCase = convertCase("PascalCase", "kebab-case"); type ABIInputItem = { name: string; @@ -58,156 +48,236 @@ const getInputType = (input: ABIInputItem) => { } }; -async function main() { - for (const folder of CONTRACT_FOLDERS) { - const pascalName = kebabToPascalCase(folder); - const constantName = kebabToConstantCase(folder); +/** + * Extracts the contract formatted name and path from the folder name + * + * @param folderPath + */ +function extractContractNameAndPath(folderPath: string) { + const pascalName = path.basename(folderPath).replace(/.sol$/, ""); + + return { + pascalName, + kebabName: pascalToKebabCase(pascalName), + abiPath: path.join(folderPath, `${pascalName}.json`), + }; +} + +type CodegenOptions = { + excludePatterns?: string[]; + outputFolder?: string; + flatten?: boolean; +}; + +export type CodegenConfig = CodegenOptions & { + /** + * The folder where the contracts are located + */ + contractsFolder: string; +}; - const GENERATED_DISCLAIMER = ` +async function codegenContract({ + abiPath = "", + abiFileJson = "", + pascalName = "", + contractFolder = "", +}) { + const GENERATED_DISCLAIMER = ` /* eslint-disable @typescript-eslint/no-explicit-any */ /** - * This file was generated by scripts/codegen.cjs - * - * Original abi file: - * - ${PACKAGE_NAME}/dist/${folder}/${pascalName}.sol/${pascalName}.json - * + * This file was generated by scripts/codegen.ts + * + * Original abi file: + * - ${abiPath} + * * DO NOT EDIT MANUALLY */ `; - const { stdout: abiFileJson } = - await $`cat node_modules/${PACKAGE_NAME}/dist/${folder}/${pascalName}.sol/${pascalName}.json`; - - const { abi, contractName } = JSON.parse(abiFileJson) as { - abi: ABIItem[]; - contractName: string; - }; - - const abiJsonFile = `${JSON.stringify({ contractName, abi }, null, 2)}`; - - const basePath = path.join("src", "contracts", folder); - - // only generate args file if there are functions with inputs - const abiFns = abi.filter( - (x) => - x.type === "function" && - x.inputs.length && - x.inputs.every((input) => input.name) - ); - - const argsFile = ` - import { encodeFunctionData } from "viem"; - - import ABI_FILE from "./${folder}.abi"; - - ${abiFns - .map(({ name, inputs }) => { - const argNames = inputs.map(({ name = "" }) => name).join(", "); - - const argsType = inputs - .map((input) => `${input.name}: ${getInputType(input)}`) - .join("; "); - - const fnName = capitalize(name); - const typeName = `${pascalName}${fnName}Args`; - - return ` - export type ${typeName} = {${argsType}} - - /** - * Factory function for ${pascalName}.${name} function args - */ - export const encode${pascalName}${fnName}Args = ({${argNames}}: ${typeName}) => [${argNames}] as const; - - /** - * Encoder function for ${pascalName}.${name} function data - */ - export const encode${pascalName}${fnName}Data = ({${argNames}}: ${typeName}) => encodeFunctionData({ - functionName: "${name}", - abi: ABI_FILE.abi, - args:[${argNames}] - }); + const { abi, contractName } = JSON.parse(abiFileJson) as { + abi: ABIItem[]; + contractName: string; + }; + + const abiJsonFile = `${JSON.stringify({ contractName, abi }, null, 2)}`; + + // only generate args file if there are functions with inputs + const abiFns = abi.filter( + (x) => + x.type === "function" && + x.inputs.length && + x.inputs.every((input) => input.name) + ); + + const argsFile = ` + import { encodeFunctionData } from "viem"; + + import ABI_FILE from "./${pascalName}.abi"; + + ${abiFns + .map(({ name, inputs }) => { + const argNames = inputs.map(({ name = "" }) => name).join(", "); + + const argsType = inputs + .map((input) => `${input.name}: ${getInputType(input)}`) + .join("; "); + + const fnName = capitalize(name); + const typeName = `${pascalName}${fnName}Args`; + + return ` + export type ${typeName} = {${argsType}} + + /** + * Factory function for ${pascalName}.${name} function args + */ + export const encode${pascalName}${fnName}Args = ({${argNames}}: ${typeName}) => [${argNames}] as const; + + /** + * Encoder function for ${pascalName}.${name} function data + */ + export const encode${pascalName}${fnName}Data = ({${argNames}}: ${typeName}) => encodeFunctionData({ + functionName: "${name}", + abi: ABI_FILE.abi, + args:[${argNames}] + }); `; - }) - .join("\n\n")} - `; + }) + .join("\n\n")} + `; - const abiFile = ` - export default ${abiJsonFile} as const; + const abiFile = ` + export default ${abiJsonFile} as const; `; - const indexFile = ` - import { Chain } from "viem"; - - import { PublicContractClient } from "../PublicContractClient"; - import ABI_FILE from "./${folder}.abi"; - - export * from "./${folder}.args"; - - export const ${constantName}_ABI = ABI_FILE.abi; - - export class ${contractName}Client extends PublicContractClient< - typeof ABI_FILE.abi - > { - static ABI = ABI_FILE.abi; - static contractName = ABI_FILE.contractName; - - constructor(options: { chain: Chain; address: \`0x\${string}\` }) { - super({ - abi: ${constantName}_ABI, - address: options.address, - chain: options.chain, - }); - } - } - `; + const subPath = path.dirname( + contractFolder.replace(config.contractsFolder, "") + ); - $`mkdir -p ${basePath}`; - - const files = [ - { - name: "index.ts", - content: indexFile, - parser: "babel-ts", - }, - { - name: `${folder}.abi.ts`, - content: abiFile, - parser: "babel-ts", - }, - { - name: `${folder}.args.ts`, - content: argsFile, - parser: "babel-ts", - }, - ]; - - await Promise.all( - files.map(async ({ name, content, parser }) => - fs.writeFile( - path.join(basePath, name), - prettier.format( - parser === "json" - ? content - : `${GENERATED_DISCLAIMER}\n\n${content}`, - { parser } - ) + const outputFolderPath = path.resolve(config.outputFolder ?? ""); + + const outputPath = path.join( + config.flatten ? outputFolderPath : path.join(outputFolderPath, subPath), + pascalName + ); + + // create base path folder + await $`mkdir -p ${outputPath}`; + + const files = [ + { + name: `${pascalName}.abi.ts`, + content: abiFile, + parser: "babel-ts", + }, + { + name: `${pascalName}.args.ts`, + content: argsFile, + parser: "babel-ts", + excluded: !abiFns.length, + }, + ].filter(({ excluded }) => !excluded); + + // write files + await Promise.all( + files.map(async ({ name, content, parser }) => + fs.writeFile( + path.join(outputPath, name), + prettier.format( + parser === "json" ? content : `${GENERATED_DISCLAIMER}\n\n${content}`, + { parser } ) ) - ); + ) + ); +} + +async function codegen(config: CodegenConfig) { + const ignored = + config.excludePatterns?.flatMap((pattern) => [ + `${config.contractsFolder}/${pattern}/**`, + `**/${pattern}/**`, + ]) ?? []; + + const contractFolders = await glob(`${config.contractsFolder}/**/**.sol`, { + onlyDirectories: true, + ignore: ignored, + }); + + const promises = contractFolders.map(async (contractFolder) => { + const { pascalName, abiPath } = extractContractNameAndPath(contractFolder); + + const { stdout: abiFileJson } = await $`cat ./${abiPath}`; + + if (!abiFileJson) { + console.log(`ABI file not found: ${abiPath}`); + return; + } + + try { + await codegenContract({ + abiFileJson, + abiPath, + contractFolder, + pascalName, + }); + } catch (error) { + console.error(`Failed to process contract ${pascalName}`, error); + } + }); + + await spinner("Generating contract ABIs", () => Promise.all(promises)); + + const summary = `Done. Generated ${chalk.green( + contractFolders.length + )} typed contract ABIs! 🎉`; + console.log(summary); + process.exit(0); +} - console.info(`Synced ${folder} contract ABI.`, { - functions: abiFns.length, - }); +const HELP_BLOCK = ` +Usage: codegen [options] + +Options: + --src The folder where the contracts are located (required) + --out The folder where the generated files will be written (required) + --exclude Comma separated list of glob patterns to exclude (optional, default: []) + --flatten Whether to flatten the output folder structure (optional, default: false) +`; + +function printMissingArgument(arg: string) { + console.error(chalk.yellow(`Missing argument: ${chalk.red(arg)}`)); + console.log(HELP_BLOCK); +} + +function validateArg(arg: string, type: string) { + if (!argv[arg] || typeof argv[arg] !== type) { + printMissingArgument(arg); + process.exit(1); } +} - console.info( - `Synced ${CONTRACT_FOLDERS.length} contract ABIs.\n`, - "Generated code can be found in ./src/contracts" - ); +/** + * parseConfig + * + * Parses the command line arguments + * @returns {CodegenConfig} + */ +function parseConfig(): CodegenConfig { + validateArg("src", "string"); + validateArg("out", "string"); + + return { + contractsFolder: String(argv["src"] ?? ""), + outputFolder: String(argv["out"] ?? ""), + excludePatterns: String(argv["exclude"] ?? "").split(","), + flatten: Boolean(argv["flatten"] ?? false), + }; } -main().catch((err) => { +const config = parseConfig(); + +codegen(config).catch((err) => { console.error(err); process.exit(1); }); From 7aede2432ac4007c47482e0d8b793e033928d5d5 Mon Sep 17 00:00:00 2001 From: Alan Soares Date: Wed, 8 Nov 2023 15:11:21 +1300 Subject: [PATCH 2/2] docs(changeset): feat: introduce @axelarjs/evm cli --- .changeset/nasty-mails-reflect.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/nasty-mails-reflect.md diff --git a/.changeset/nasty-mails-reflect.md b/.changeset/nasty-mails-reflect.md new file mode 100644 index 000000000..4cae3aa4a --- /dev/null +++ b/.changeset/nasty-mails-reflect.md @@ -0,0 +1,5 @@ +--- +"@axelarjs/evm": patch +--- + +feat: introduce @axelarjs/evm cli