From fd019aea4efd41d409ec8c239c9d899ca7e2af2e Mon Sep 17 00:00:00 2001 From: Jack Hamer <47187316+JackHamer09@users.noreply.github.com> Date: Tue, 30 Jan 2024 13:58:41 +0200 Subject: [PATCH] feat: add contract encode command (#118) --- README.md | 1 + docs/contract-interaction.md | 42 +++++++- src/commands/contract/encode.ts | 131 +++++++++++++++++++++++++ src/commands/contract/index.ts | 1 + src/commands/contract/read.ts | 10 +- src/commands/contract/utils/helpers.ts | 45 +++++++-- src/commands/contract/write.ts | 10 +- 7 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 src/commands/contract/encode.ts diff --git a/README.md b/README.md index 58d1fb2d..0f477a89 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Run `npx zksync-cli dev` to see the full list of commands. ### Contract interaction commands - `npx zksync-cli contract read`: run read-only contract methods - `npx zksync-cli contract write`: send transactions to the contract +- `npx zksync-cli contract encode`: get calldata from the contract method See full documentation and advanced examples [here](./docs/contract-interaction.md). diff --git a/docs/contract-interaction.md b/docs/contract-interaction.md index 12157e15..d9d7dff5 100644 --- a/docs/contract-interaction.md +++ b/docs/contract-interaction.md @@ -1,5 +1,5 @@ # Contract interaction -The zksync-cli tool, now enhanced with `contract read` and `contract write` commands, offers efficient ways for developers to interact with smart contracts on zkSync. These commands automate tasks such as method verification, ABI handling, output decoding, and proxy contract processing. +The zksync-cli tool, now enhanced with `contract read`, `contract write` and `contract encode` commands, offers efficient ways for developers to interact with smart contracts on zkSync. These commands automate tasks such as method verification, ABI handling, output decoding, and proxy contract processing. ### Table of contents - [Contract Read - Running read-only methods](#contract-read) @@ -7,6 +7,7 @@ The zksync-cli tool, now enhanced with `contract read` and `contract write` comm - [Examples](#examples) - [Basic read example](#basic-read-example) - [Basic write example](#basic-write-example) + - [Basic encode example](#basic-encode-example) - [Using local ABI file](#using-local-abi-file) - [Running read on behalf of another address](#running-read-on-behalf-of-another-address) - [Write operation with value transfer](#write-operation-with-value-transfer) @@ -186,6 +187,45 @@ npx zksync-cli contract write \
+#### Basic encode example +```bash +npx zksync-cli contract encode +``` +This command allows you to encode contract method signature and arguments into raw calldata (e.g. `0x1234...`). + +You will need to select a **method (function) to encode**. +- Enter method signature manually, for example `transfer(address,uint256)`. + ```bash + ? Enter method to call: transfer(address,uint256) + ``` +- Alternatively, you can specify the ABI file using the `--abi` option. [See example](#using-local-abi-file) + ```bash + ? Contract method to call + ────────── Provided contract ────────── + ❯ approve(address spender, uint256 amount) returns (bool) + transfer(address to, uint256 amount) returns (bool) + ─────────────────────────────────────── + Type method manually + ``` + +After that, you will be prompted to enter **arguments** for the method, one by one. +```bash +? Provide method arguments: +? [1/2] to (address): 0xa1cf087DB965Ab02Fb3CFaCe1f5c63935815f044 +? [2/2] amount (uint256): 1 +``` + +When finished you will see the encoded data. +```bash +✔ Encoded data: 0xa41368620000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000c48656c6c6f20776f726c64210000000000000000000000000000000000000000 +``` + +
+ +--- + +
+ ### Using local ABI file You can specify a local ABI file using the `--abi` option. It should be a JSON file with either ABI data (array) or contract artifact which you get after compiling your contracts. ```bash diff --git a/src/commands/contract/encode.ts b/src/commands/contract/encode.ts new file mode 100644 index 00000000..b689db0a --- /dev/null +++ b/src/commands/contract/encode.ts @@ -0,0 +1,131 @@ +import chalk from "chalk"; +import { ethers } from "ethers"; +import inquirer from "inquirer"; + +import Program from "./command.js"; +import { abiOption, argumentsOption, methodOption } from "./common/options.js"; +import { encodeData, encodeParam, getFragmentFromSignature, getInputsFromSignature } from "./utils/formatters.js"; +import { readAbiFromFile, askAbiMethod, formatMethodString } from "./utils/helpers.js"; +import { logFullCommandFromOptions, optionNameToParam } from "../../utils/helpers.js"; +import Logger from "../../utils/logger.js"; + +import type { ABI } from "./utils/helpers.js"; +import type { Command } from "commander"; +import type { DistinctQuestion } from "inquirer"; + +type EncodeOptions = { + method?: string; + arguments?: string[]; + abi?: string; +}; + +// ---------------- +// prompts +// ---------------- + +const askMethod = async (contractAbi: ABI | undefined, options: EncodeOptions) => { + if (options.method) { + return; + } + + const methodByAbi = await askAbiMethod({ abi: contractAbi }); + if (methodByAbi !== "manual") { + const fullMethodName = methodByAbi.format(ethers.utils.FormatTypes.full); + options.method = formatMethodString(fullMethodName); + return; + } + + const answers: Pick = await inquirer.prompt( + [ + { + message: "Enter method to encode", + name: optionNameToParam(methodOption.long!), + type: "input", + validate: (input: string) => { + try { + getFragmentFromSignature(input); // throws if invalid + return true; + } catch { + return `Invalid method signature. Example: ${chalk.blueBright("balanceOf(address)")}`; + } + }, + }, + ], + options + ); + + options.method = answers.method; +}; + +const askArguments = async (method: string, options: EncodeOptions) => { + if (options.arguments) { + return; + } + const inputs = getInputsFromSignature(method); + if (!inputs.length) { + options.arguments = []; + return; + } + Logger.info(chalk.green("?") + chalk.bold(" Provide method arguments:")); + const prompts: DistinctQuestion[] = []; + + inputs.forEach((input, index) => { + let name = chalk.gray(`[${index + 1}/${inputs.length}]`); + if (input.name) { + name += ` ${input.name}`; + name += chalk.gray(` (${input.type})`); + } else { + name += ` ${input.type}`; + } + + prompts.push({ + message: name, + name: index.toString(), + type: "input", + validate: (value: string) => { + try { + encodeParam(input, value); // throws if invalid + return true; + } catch (error) { + return `${chalk.redBright( + "Failed to encode provided argument: " + (error instanceof Error ? error.message : error) + )}`; + } + }, + }); + }); + + const answers = await inquirer.prompt(prompts); + options.arguments = Object.values(answers); +}; + +// ---------------- +// request handler +// ---------------- + +export const handler = async (options: EncodeOptions, context: Command) => { + try { + let abi: ABI | undefined; + if (options.abi) { + abi = readAbiFromFile(options.abi); + Logger.info(chalk.gray("Using provided ABI file")); + } + await askMethod(abi, options); + await askArguments(options.method!, options); + + const data = encodeData(options.method!, options.arguments!); + Logger.info(""); + Logger.info(chalk.greenBright("✔ Encoded data: ") + data); + logFullCommandFromOptions(options, context, { emptyLine: true }); + } catch (error) { + Logger.error("There was an error while performing encoding"); + Logger.error(error); + } +}; + +Program.command("encode") + .addOption(methodOption) + .addOption(argumentsOption) + .addOption(abiOption) + .description("Get calldata (e.g. 0x1234) from contract method signature and arguments") + .action(handler); diff --git a/src/commands/contract/index.ts b/src/commands/contract/index.ts index 451a6e1b..deed9050 100644 --- a/src/commands/contract/index.ts +++ b/src/commands/contract/index.ts @@ -1,4 +1,5 @@ import "./read.js"; import "./write.js"; +import "./encode.js"; import "./command.js"; // registers all the commands above diff --git a/src/commands/contract/read.ts b/src/commands/contract/read.ts index 44c1da00..2ad0764c 100644 --- a/src/commands/contract/read.ts +++ b/src/commands/contract/read.ts @@ -21,7 +21,13 @@ import { getInputValues, getInputsFromSignature, } from "./utils/formatters.js"; -import { checkIfMethodExists, getContractInfoWithLoader, readAbiFromFile, askAbiMethod } from "./utils/helpers.js"; +import { + checkIfMethodExists, + getContractInfoWithLoader, + readAbiFromFile, + askAbiMethod, + formatMethodString, +} from "./utils/helpers.js"; import { chainOption, l2RpcUrlOption } from "../../common/options.js"; import { l2Chains } from "../../data/chains.js"; import { getL2Provider, logFullCommandFromOptions, optionNameToParam } from "../../utils/helpers.js"; @@ -63,7 +69,7 @@ const askMethod = async (contractInfo: ContractInfo, options: CallOptions) => { const methodByAbi = await askAbiMethod(contractInfo, "read"); if (methodByAbi !== "manual") { const fullMethodName = methodByAbi.format(ethers.utils.FormatTypes.full); - options.method = fullMethodName.substring("function ".length).replace(/\).+$/, ")"); // remove "function " prefix and return type + options.method = formatMethodString(fullMethodName); if (methodByAbi.outputs) { options.outputTypes = methodByAbi.outputs.map((output) => output.type); } diff --git a/src/commands/contract/utils/helpers.ts b/src/commands/contract/utils/helpers.ts index d39403c4..eb9a32a6 100644 --- a/src/commands/contract/utils/helpers.ts +++ b/src/commands/contract/utils/helpers.ts @@ -21,15 +21,22 @@ export type ContractInfo = { implementation?: ContractInfo; }; -export const getMethodsFromAbi = (abi: ABI, type: "read" | "write"): ethers.utils.FunctionFragment[] => { - if (type === "read") { +export const formatMethodString = (method: string): string => { + // remove "function " prefix and return type + // e.g. "greet() view returns (string)" -> "greet()" + return method.substring("function ".length).replace(/\).+$/, ")"); +}; + +export const getMethodsFromAbi = (abi: ABI, type: "read" | "write" | "any"): ethers.utils.FunctionFragment[] => { + const getReadMethods = () => { const readMethods = abi.filter( (fragment) => fragment.type === "function" && (fragment.stateMutability === "view" || fragment.stateMutability === "pure") ); const contractInterface = new ethers.utils.Interface(readMethods); return contractInterface.fragments as ethers.utils.FunctionFragment[]; - } else { + }; + const getWriteMethods = () => { const writeMethods = abi.filter( (fragment) => fragment.type === "function" && @@ -37,7 +44,13 @@ export const getMethodsFromAbi = (abi: ABI, type: "read" | "write"): ethers.util ); const contractInterface = new ethers.utils.Interface(writeMethods); return contractInterface.fragments as ethers.utils.FunctionFragment[]; + }; + if (type === "read") { + return getReadMethods(); + } else if (type === "write") { + return getWriteMethods(); } + return [...getReadMethods(), ...getWriteMethods()]; }; export const checkIfMethodExists = (contractInfo: ContractInfo, method: string) => { @@ -121,8 +134,11 @@ export const getContractInfoWithLoader = async ( }; export const askAbiMethod = async ( - contractInfo: ContractInfo, - type: "read" | "write" + contractInfo: { + abi?: ContractInfo["abi"]; + implementation?: ContractInfo["implementation"]; + }, + type: "read" | "write" | "any" = "any" ): Promise => { if (!contractInfo.abi && !contractInfo.implementation?.abi) { return "manual"; @@ -148,7 +164,7 @@ export const askAbiMethod = async ( }; const formatFragment = (fragment: ethers.utils.FunctionFragment): DistinctChoice => { let name = fragment.format(ethers.utils.FormatTypes.full); - if (type === "write" && name.includes(" returns ")) { + if ((type === "write" || type === "any") && name.includes(" returns ")) { name = name.substring(0, name.indexOf(" returns ")); // remove return type for write methods } return { @@ -161,6 +177,7 @@ export const askAbiMethod = async ( const separators = { noReadMethods: { type: "separator", line: chalk.white("No read methods found") } as DistinctChoice, noWriteMethods: { type: "separator", line: chalk.white("No write methods found") } as DistinctChoice, + noMethods: { type: "separator", line: chalk.white("No methods found") } as DistinctChoice, contractNotVerified: { type: "separator", line: chalk.white("Contract is not verified") } as DistinctChoice, }; choices.push(formatSeparator("Provided contract")); @@ -169,7 +186,13 @@ export const askAbiMethod = async ( if (methods.length) { choices.push(...methods.map(formatFragment)); } else { - choices.push(type === "read" ? separators.noReadMethods : separators.noWriteMethods); + if (type === "read") { + choices.push(separators.noReadMethods); + } else if (type === "write") { + choices.push(separators.noWriteMethods); + } else { + choices.push(separators.noMethods); + } } } else { choices.push(separators.contractNotVerified); @@ -181,7 +204,13 @@ export const askAbiMethod = async ( if (implementationMethods.length) { choices.push(...implementationMethods.map(formatFragment)); } else { - choices.push(type === "read" ? separators.noReadMethods : separators.noWriteMethods); + if (type === "read") { + choices.push(separators.noReadMethods); + } else if (type === "write") { + choices.push(separators.noWriteMethods); + } else { + choices.push(separators.noMethods); + } } } else { choices.push(separators.contractNotVerified); diff --git a/src/commands/contract/write.ts b/src/commands/contract/write.ts index 6fa85361..6b42b5fa 100644 --- a/src/commands/contract/write.ts +++ b/src/commands/contract/write.ts @@ -14,7 +14,13 @@ import { showTransactionInfoOption, } from "./common/options.js"; import { encodeData, encodeParam, getFragmentFromSignature, getInputsFromSignature } from "./utils/formatters.js"; -import { checkIfMethodExists, getContractInfoWithLoader, readAbiFromFile, askAbiMethod } from "./utils/helpers.js"; +import { + checkIfMethodExists, + getContractInfoWithLoader, + readAbiFromFile, + askAbiMethod, + formatMethodString, +} from "./utils/helpers.js"; import { chainOption, l2RpcUrlOption, privateKeyOption } from "../../common/options.js"; import { l2Chains } from "../../data/chains.js"; import { getL2Provider, getL2Wallet, logFullCommandFromOptions, optionNameToParam } from "../../utils/helpers.js"; @@ -52,7 +58,7 @@ const askMethod = async (contractInfo: ContractInfo, options: WriteOptions) => { const methodByAbi = await askAbiMethod(contractInfo, "write"); if (methodByAbi !== "manual") { const fullMethodName = methodByAbi.format(ethers.utils.FormatTypes.full); - options.method = fullMethodName.substring("function ".length).replace(/\).+$/, ")"); // remove "function " prefix and return type + options.method = formatMethodString(fullMethodName); return; }