diff --git a/.changeset/tidy-moons-shake.md b/.changeset/tidy-moons-shake.md new file mode 100644 index 00000000..f626bc2a --- /dev/null +++ b/.changeset/tidy-moons-shake.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": minor +--- + +expose the deposit commands as library functions: `depositEther`, `depositErc20`, `depositErc721`, `depositErc1155` and `depositErc1155Batch` diff --git a/.changeset/wild-crabs-invent.md b/.changeset/wild-crabs-invent.md new file mode 100644 index 00000000..947a4282 --- /dev/null +++ b/.changeset/wild-crabs-invent.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": minor +--- + +expose the CLI as a library, so `build`, `run`, `hash` and the other commands can be called programmatically from a script: `import { build } from "@cartesi/cli"` diff --git a/apps/cli/README.md b/apps/cli/README.md index 8c52e002..3290c21e 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -17,3 +17,61 @@ cartesi --help ``` More documentation at [https://docs.cartesi.io](https://docs.cartesi.io). + +## Library + +Every command of the CLI is also available as a function, so applications can be +built, run and inspected from a script, without going through the command line: + +```shell +npm install @cartesi/cli +``` + +```ts +import { build, depositErc20, hash, run, send } from "@cartesi/cli"; + +// build the application, same as `cartesi build` +await build(); + +// read the template hash of the machine snapshot, same as `cartesi hash` +console.log(await hash()); + +// start a local node and deploy the application to it, same as `cartesi run` +const node = await run({ epochLength: 10 }); +console.log(`running at ${node.url}, deployed at ${node.deployment?.address}`); + +// send an input to the application, same as `cartesi send` +await send({ input: "hello" }); + +// deposit tokens to the application, same as `cartesi deposit erc20` +await depositErc20({ amount: "1.5" }); + +await node.stop(); +``` + +The following functions are available: `addressBook`, `build`, `clean`, +`create`, `depositErc20`, `depositErc721`, `depositErc1155`, +`depositErc1155Batch`, `depositEther`, `doctor`, `hash`, `logs`, `run`, `send`, +`shell` and `status`. + +A few things to keep in mind: + +- functions operate on the current working directory, just like the CLI, and + read `cartesi.toml` from it by default. Functions that take a configuration + accept a path, a list of paths (merged in order), or an already parsed + `Config` object; +- functions are silent, and never write to the terminal. Pass + `progress: "default"` (or `"verbose"`) to get the same output as the CLI; +- functions throw on error, and never terminate the process; +- `run` returns a handle of the environment, which keeps running in the + background until `stop()` is called. Use `deploy()` to redeploy the + application after a rebuild; +- `send` and the `deposit*` functions resolve the application address, the + sender and the RPC URL from the running project, and never prompt for them. + Amounts are given in the base unit of the asset as a `bigint`, or in its + display unit as a string (`"1.5"`). A deposit that cannot be made throws a + `DepositError` (`InsufficientBalanceError`, `InvalidAmountError` or + `TokenNotFoundError`). + +The package is typed, and the types of the configuration file (`Config`, +`DriveConfig`, `MachineConfig`, ...) are exported as well. diff --git a/apps/cli/build.ts b/apps/cli/build.ts index 3ed24ffb..133a836e 100644 --- a/apps/cli/build.ts +++ b/apps/cli/build.ts @@ -1,4 +1,4 @@ -// build for npm package +// build for npm package: the CLI entrypoint (executable) and the library entrypoint await Bun.build({ banner: "#!/usr/bin/env node", entrypoints: ["./src/index.ts"], @@ -8,6 +8,14 @@ await Bun.build({ target: "node", }); +await Bun.build({ + entrypoints: ["./src/lib.ts"], + minify: true, + outdir: "dist", + sourcemap: true, + target: "node", +}); + // build bun binaries for all supported platforms const targets: Bun.Build.CompileTarget[] = [ "bun-darwin-arm64", diff --git a/apps/cli/package.json b/apps/cli/package.json index 95ad6649..16d11875 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -9,7 +9,14 @@ "type": "module", "homepage": "https://github.com/cartesi/cli", "license": "Apache-2.0", - "exports": "./dist/index.js", + "main": "./dist/lib.js", + "exports": { + ".": { + "types": "./dist/types/lib.d.ts", + "default": "./dist/lib.js" + }, + "./package.json": "./package.json" + }, "repository": "cartesi/cli", "files": [ "/dist" @@ -62,11 +69,12 @@ "typescript": "^5.9.2" }, "scripts": { - "build": "run-s clean codegen compile", + "build": "run-s clean codegen compile types", "clean": "rimraf dist", "codegen": "run-p codegen:wagmi", "codegen:wagmi": "wagmi generate", "compile": "bun build.ts", + "types": "tsc -p tsconfig.build.json", "lint": "biome lint", "posttest": "bun lint", "test": "bun test" @@ -75,5 +83,5 @@ "node": ">=20.0.0" }, "bugs": "https://github.com/cartesi/cli/issues", - "types": "dist/index.d.ts" + "types": "./dist/types/lib.d.ts" } diff --git a/apps/cli/src/api/address-book.ts b/apps/cli/src/api/address-book.ts new file mode 100644 index 00000000..0a1cef5f --- /dev/null +++ b/apps/cli/src/api/address-book.ts @@ -0,0 +1,24 @@ +import { type AddressBook, getAddressBook, getProjectName } from "../base.js"; + +export type AddressBookOptions = { + /** + * Name of the project (used by docker compose and cartesi-rollups-node). + * @default basename of the current working directory + */ + projectName?: string; +}; + +/** + * Get the addresses of all smart contracts deployed to the runtime environment + * of the application, indexed by contract name. + * @param options address book options + * @returns map of contract name to contract address + */ +export const addressBook = async ( + options: AddressBookOptions = {}, +): Promise => { + const projectName = getProjectName(options); + return getAddressBook({ projectName }); +}; + +export type { AddressBook }; diff --git a/apps/cli/src/api/build.ts b/apps/cli/src/api/build.ts new file mode 100644 index 00000000..05714e9b --- /dev/null +++ b/apps/cli/src/api/build.ts @@ -0,0 +1,195 @@ +import chalk from "chalk"; +import fs from "fs-extra"; +import { Listr, type ListrTask } from "listr2"; +import path from "node:path"; +import tmp from "tmp"; +import { getContextPath } from "../base.js"; +import { + buildDirectory, + buildDocker, + buildEmpty, + buildNone, + buildTar, +} from "../builder/index.js"; +import type { Config, DriveConfig, ImageInfo } from "../config.js"; +import { bootMachine } from "../machine.js"; +import { + type ConfigOptions, + listrRenderer, + type ProgressOptions, + resolveConfig, +} from "./types.js"; + +export type BuildOptions = ConfigOptions & + ProgressOptions & { + /** + * Keep intermediate files, used for debugging. + * @default false + */ + debug?: boolean; + + /** + * Only build the drives, do not boot the machine. + * @default false + */ + drivesOnly?: boolean; + }; + +export type BuildResult = { + /** Application configuration used by the build. */ + config: Config; + + /** Directory where the drives and the machine snapshot were written to. */ + destination: string; + + /** Information of the docker image used to build the root drive, if any. */ + imageInfo?: ImageInfo; +}; + +// context for Listr build tasks +type BuildContext = { + config: Config; + debug: boolean; + destination: string; + imageInfo?: ImageInfo; +}; + +const buildDriveTask = ( + name: string, + drive: DriveConfig, +): ListrTask => ({ + title: `Building drive ${chalk.cyan(name)}`, + task: async (ctx, task) => { + const { config, debug, destination } = ctx; + const sdk = config.sdk; + const reporter = (line: string) => { + task.output = line; + }; + + switch (drive.builder) { + case "directory": { + await buildDirectory( + name, + drive, + sdk, + destination, + debug, + reporter, + ); + break; + } + case "docker": { + const imageInfo = await buildDocker( + name, + drive, + sdk, + destination, + debug, + reporter, + ); + if (imageInfo && name === "root") { + // only set image info for root drive + ctx.imageInfo = imageInfo; + } + break; + } + case "empty": { + await buildEmpty(name, drive, sdk, destination); + break; + } + case "tar": { + await buildTar(name, drive, sdk, destination, reporter); + break; + } + case "none": { + await buildNone(name, drive, destination); + break; + } + } + task.title = `Build drive ${chalk.cyan(name)}`; + }, +}); + +/** + * Build the application, by building the Cartesi machine drives, configuring a + * machine and booting it, so a machine snapshot is created at `.cartesi/image`. + * + * Drives are built relative to the current working directory, so are the + * configuration files, if provided as paths. + * + * @param options build options + * @returns configuration used and location of the build artifacts + */ +export const build = async ( + options: BuildOptions = {}, +): Promise => { + const { debug = false, drivesOnly = false, progress = "silent" } = options; + + // clean up temp files we create along the process + tmp.setGracefulCleanup(); + + // get application configuration, from a Config object or 'cartesi.toml' + const config = resolveConfig(options.config); + + // destination directory for image and intermediate files + const destination = path.resolve(getContextPath()); + + // prepare context directory + await fs.emptyDir(destination); // XXX: make it less error prone + + // build context + const ctx: BuildContext = { + config, + debug, + destination, + imageInfo: undefined, + }; + + // tasks to build drives + const driveTasks = Object.entries(config.drives).map(([name, drive]) => + buildDriveTask(name, drive), + ); + + const builds = new Listr( + [ + { + title: "Build drives", + task: async (_ctx, task) => { + return task.newListr(driveTasks, { + concurrent: true, + rendererOptions: { + collapseSubtasks: false, + }, + ctx, + }); + }, + }, + ], + { ctx, renderer: listrRenderer(progress) }, + ); + const result = await builds.run(); + + // if only build drives, quit here + if (drivesOnly) { + return { config, destination, imageInfo: result.imageInfo }; + } + + // create machine snapshot + await bootMachine( + config, + result.imageInfo, + { + finalHash: true, + store: "image", + }, + { + cwd: destination, + stdio: progress === "silent" ? "pipe" : "inherit", + }, + ); + + // make snapshot readable by all users, because cartesi-machine sets to 600 + await fs.chmod(path.join(destination, "image"), 0o755); + + return { config, destination, imageInfo: result.imageInfo }; +}; diff --git a/apps/cli/src/api/clean.ts b/apps/cli/src/api/clean.ts new file mode 100644 index 00000000..11964a7d --- /dev/null +++ b/apps/cli/src/api/clean.ts @@ -0,0 +1,10 @@ +import fs from "fs-extra"; +import { getContextPath } from "../base.js"; + +/** + * Delete all cached build artifacts of the application, by emptying the + * `.cartesi` directory of the current working directory. + */ +export const clean = async (): Promise => { + await fs.emptyDir(getContextPath()); +}; diff --git a/apps/cli/src/api/connection.ts b/apps/cli/src/api/connection.ts new file mode 100644 index 00000000..22d6105f --- /dev/null +++ b/apps/cli/src/api/connection.ts @@ -0,0 +1,86 @@ +import type { Address } from "viem"; +import { getProjectName } from "../base.js"; +import { getApplicationAddress } from "../exec/rollups.js"; +import { connect, type DevnetClient } from "../wallet.js"; + +export type ConnectionOptions = { + /** + * Address of the application to interact with. + * @default the application deployed to the local node + */ + application?: Address; + + /** + * Address of the transaction sender, which is impersonated. + * @default the first account of the devnet + */ + from?: Address; + + /** + * Name of the project (used by docker compose and cartesi-rollups-node). + * @default basename of the current working directory + */ + projectName?: string; + + /** + * RPC URL of the Cartesi Devnet. + * @default the anvil of the running project + */ + rpcUrl?: string; + + /** + * Client to send the transactions with. + * @default a client connected to the devnet of the running project + */ + client?: DevnetClient; +}; + +export type Connection = { + /** Address of the application to interact with. */ + application: Address; + + /** Client connected to the devnet. */ + client: DevnetClient; + + /** Address of the transaction sender. */ + from: Address; + + /** Name of the project. */ + projectName: string; +}; + +/** + * Resolve everything needed to send a transaction to a local environment: the + * client, the sender and the application address, querying the running project + * for whatever was not explicitly provided. + * @param options connection options + * @returns the resolved connection + */ +export const resolveConnection = async ( + options: ConnectionOptions, +): Promise => { + const projectName = getProjectName(options); + + // resolve the application address from the local node, if not provided + const application = + options.application ?? (await getApplicationAddress({ projectName })); + if (!application) { + throw new Error( + `Unable to resolve the address of the application of project '${projectName}', make sure it is deployed, or define 'application'`, + ); + } + + // connect to anvil, without prompting for the RPC URL + const client = + options.client ?? + (await connect({ + interactive: false, + projectName, + rpcUrl: options.rpcUrl, + })); + + // the transaction sender, impersonated + const from = options.from ?? (await client.getAddresses())[0]; + + return { application, client, from, projectName }; +}; diff --git a/apps/cli/src/api/create.ts b/apps/cli/src/api/create.ts new file mode 100644 index 00000000..b3e8618f --- /dev/null +++ b/apps/cli/src/api/create.ts @@ -0,0 +1,49 @@ +import { download, type DownloadTemplateResult } from "../template.js"; + +/** Templates available at the `cartesi/application-templates` repository. */ +export const TEMPLATES = [ + "cpp", + "cpp-low-level", + "go", + "java", + "javascript", + "lua", + "python", + "ruby", + "rust", + "typescript", +] as const; + +export type Template = (typeof TEMPLATES)[number]; + +/** Default branch of the `cartesi/application-templates` repository. */ +export const DEFAULT_TEMPLATES_BRANCH = "prerelease/sdk-12"; + +export type CreateOptions = { + /** Application name, also used as the directory the application is created at. */ + name: string; + + /** Name of the template to use, one of {@link TEMPLATES}. */ + template: string; + + /** + * Branch of the `cartesi/application-templates` repository to use. + * @default DEFAULT_TEMPLATES_BRANCH + */ + branch?: string; +}; + +/** + * Create an application from a template of the `cartesi/application-templates` + * repository. + * @param options create options + * @returns directory the application was created at, and its source repository + */ +export const create = async ( + options: CreateOptions, +): Promise => { + const { branch = DEFAULT_TEMPLATES_BRANCH, name, template } = options; + return download(template, branch, name); +}; + +export type { DownloadTemplateResult }; diff --git a/apps/cli/src/api/deposit/common.ts b/apps/cli/src/api/deposit/common.ts new file mode 100644 index 00000000..277177ff --- /dev/null +++ b/apps/cli/src/api/deposit/common.ts @@ -0,0 +1,95 @@ +import chalk from "chalk"; +import ora from "ora"; +import { type Address, type Hash, type Hex, parseUnits } from "viem"; +import type { ConnectionOptions } from "../connection.js"; +import type { ProgressOptions } from "../types.js"; + +/** Base class of the errors of a deposit that could not be made. */ +export class DepositError extends Error { + constructor(message: string) { + super(message); + this.name = "DepositError"; + } +} + +/** The sender does not own enough of the asset being deposited. */ +export class InsufficientBalanceError extends DepositError { + constructor(message = "Insufficient balance") { + super(message); + this.name = "InsufficientBalanceError"; + } +} + +/** The amount being deposited is not valid. */ +export class InvalidAmountError extends DepositError { + constructor(message: string) { + super(message); + this.name = "InvalidAmountError"; + } +} + +/** The token being deposited does not exist. */ +export class TokenNotFoundError extends DepositError { + constructor(message: string) { + super(message); + this.name = "TokenNotFoundError"; + } +} + +/** + * An amount of an asset, either in its base unit (as a `bigint`), or in its + * display unit (as a string, like `"1.5"`), converted using the number of + * decimals of the asset. + */ +export type Amount = bigint | string; + +export type DepositOptions = ConnectionOptions & + ProgressOptions & { + /** + * Additional data forwarded to the application. + * @default "0x" + */ + execLayerData?: Hex; + }; + +export type PortalDepositOptions = DepositOptions & { + /** + * Additional data forwarded to the base layer. + * @default "0x" + */ + baseLayerData?: Hex; +}; + +export type DepositResult = { + /** Address of the application the asset was deposited to. */ + application: Address; + + /** Address of the sender of the deposit. */ + from: Address; + + /** Hash of the deposit transaction. */ + transactionHash: Hash; + + /** Hash of the approval transaction, when an approval was necessary. */ + approvalTransactionHash?: Hash; +}; + +/** + * Convert an {@link Amount} to the base unit of the asset. + * @param amount amount in base units, or in display units as a string + * @param decimals number of decimals of the asset + * @returns the amount in the base unit of the asset + */ +export const parseAmount = (amount: Amount, decimals: number): bigint => + typeof amount === "bigint" ? amount : parseUnits(amount, decimals); + +/** + * Create the spinner used to report the progress of a deposit, silent unless + * the caller asked for progress. + */ +export const depositSpinner = (options: ProgressOptions) => + ora({ isSilent: (options.progress ?? "silent") === "silent" }); + +/** Label of an application address, used in progress messages. */ +export const applicationLabel = (application: Address) => + chalk.cyan(application); diff --git a/apps/cli/src/api/deposit/erc1155.ts b/apps/cli/src/api/deposit/erc1155.ts new file mode 100644 index 00000000..04bc94ca --- /dev/null +++ b/apps/cli/src/api/deposit/erc1155.ts @@ -0,0 +1,303 @@ +import chalk from "chalk"; +import type { Address, Hash } from "viem"; +import { + erc1155BatchPortalAbi, + erc1155BatchPortalAddress, + erc1155SinglePortalAbi, + erc1155SinglePortalAddress, + testMultiTokenAbi, + testMultiTokenAddress, +} from "../../contracts.js"; +import type { DevnetClient } from "../../wallet.js"; +import { resolveConnection } from "../connection.js"; +import { + type Amount, + applicationLabel, + type DepositResult, + depositSpinner, + InsufficientBalanceError, + InvalidAmountError, + parseAmount, + type PortalDepositOptions, +} from "./common.js"; + +export type DepositErc1155Options = PortalDepositOptions & { + /** ID of the token to deposit. */ + tokenId: Amount; + + /** Number of units of the token to deposit. */ + amount: Amount; + + /** + * Address of the ERC-1155 token contract. + * @default the test multi token deployed to the devnet + */ + token?: Address; +}; + +export type DepositErc1155Result = DepositResult & { + /** ID of the token deposited. */ + tokenId: bigint; + + /** Number of units deposited. */ + amount: bigint; + + /** Address of the token contract. */ + token: Address; +}; + +export type DepositErc1155BatchOptions = PortalDepositOptions & { + /** IDs of the tokens to deposit. */ + tokenIds: Amount[]; + + /** Number of units of each token to deposit, in the same order. */ + amounts: Amount[]; + + /** + * Address of the ERC-1155 token contract. + * @default the test multi token deployed to the devnet + */ + token?: Address; +}; + +export type DepositErc1155BatchResult = DepositResult & { + /** IDs of the tokens deposited. */ + tokenIds: bigint[]; + + /** Number of units deposited of each token, in the same order. */ + amounts: bigint[]; + + /** Address of the token contract. */ + token: Address; +}; + +/** + * Deposit units of a single ERC-1155 token to an application running on a local + * environment. The portal is approved to transfer the tokens, if it is not + * already. + * @param options deposit options + * @returns the deposit made, and the transaction that made it + */ +export const depositErc1155 = async ( + options: DepositErc1155Options, +): Promise => { + const { + baseLayerData = "0x", + execLayerData = "0x", + token = testMultiTokenAddress, + } = options; + const { application, client, from } = await resolveConnection(options); + + const tokenId = parseAmount(options.tokenId, 0); + const amount = parseAmount(options.amount, 0); + + // ensure amount is positive + if (amount <= 0n) { + throw new InvalidAmountError( + "Amount of tokens to be deposited, must be greater than zero.", + ); + } + + // check balance + const balance = await client.readContract({ + abi: testMultiTokenAbi, + address: token, + functionName: "balanceOf", + args: [from, tokenId], + }); + if (balance < amount) { + throw new InsufficientBalanceError(); + } + + const progress = depositSpinner(options); + + // approve the portal, if needed + const approvalTransactionHash = await approvePortal({ + client, + from, + portal: erc1155SinglePortalAddress, + progress, + token, + }); + + // simulate deposit call + const { request } = await client.simulateContract({ + abi: erc1155SinglePortalAbi, + account: from, + address: erc1155SinglePortalAddress, + functionName: "depositSingleERC1155Token", + args: [ + token, + application, + tokenId, + amount, + baseLayerData, + execLayerData, + ], + }); + + // for messages + const amountLabel = `${chalk.cyan(amount)} units of token id ${tokenId}`; + + // send deposit + progress.start( + `Depositing ${amountLabel} to ${applicationLabel(application)}...`, + ); + const transactionHash = await client.writeContract(request); + await client.waitForTransactionReceipt({ hash: transactionHash }); + progress.succeed( + `Deposited ${amountLabel} to ${applicationLabel(application)}`, + ); + + return { + amount, + application, + approvalTransactionHash, + from, + token, + tokenId, + transactionHash, + }; +}; + +/** + * Deposit units of several ERC-1155 tokens to an application running on a local + * environment, in a single transaction. The portal is approved to transfer the + * tokens, if it is not already. + * @param options deposit options + * @returns the deposit made, and the transaction that made it + */ +export const depositErc1155Batch = async ( + options: DepositErc1155BatchOptions, +): Promise => { + const { + baseLayerData = "0x", + execLayerData = "0x", + token = testMultiTokenAddress, + } = options; + const { application, client, from } = await resolveConnection(options); + + const tokenIds = options.tokenIds.map((tokenId) => parseAmount(tokenId, 0)); + const amounts = options.amounts.map((amount) => parseAmount(amount, 0)); + + if (tokenIds.length !== amounts.length) { + throw new InvalidAmountError( + "Token IDs and amounts must have the same length.", + ); + } + + for (const [index, amount] of amounts.entries()) { + if (amount <= 0n) { + throw new InvalidAmountError( + `Amount of token Id: ${tokenIds[index]} to be deposited, must be greater than zero.`, + ); + } + } + + // check balances + for (const [index, tokenId] of tokenIds.entries()) { + const balance = await client.readContract({ + abi: testMultiTokenAbi, + address: token, + functionName: "balanceOf", + args: [from, tokenId], + }); + if (balance < amounts[index]) { + throw new InsufficientBalanceError( + `Insufficient balance for token ID ${tokenId}`, + ); + } + } + + const progress = depositSpinner(options); + + // approve the portal, if needed + const approvalTransactionHash = await approvePortal({ + client, + from, + portal: erc1155BatchPortalAddress, + progress, + token, + }); + + // simulate batch deposit call + const { request } = await client.simulateContract({ + abi: erc1155BatchPortalAbi, + account: from, + address: erc1155BatchPortalAddress, + functionName: "depositBatchERC1155Token", + args: [ + token, + application, + tokenIds, + amounts, + baseLayerData, + execLayerData, + ], + }); + + // for messages + const amountLabel = tokenIds + .map( + (tokenId, index) => + `${chalk.cyan(amounts[index])} units of token id ${tokenId}`, + ) + .join(", "); + + // send deposit + progress.start(`Depositing tokens to ${applicationLabel(application)}...`); + const transactionHash = await client.writeContract(request); + await client.waitForTransactionReceipt({ hash: transactionHash }); + progress.succeed( + `Deposited ${amountLabel} to ${applicationLabel(application)}`, + ); + + return { + amounts, + application, + approvalTransactionHash, + from, + token, + tokenIds, + transactionHash, + }; +}; + +/** + * Approve a portal to transfer the tokens of the sender, if it is not approved + * already. + * @returns the hash of the approval transaction, if one was necessary + */ +const approvePortal = async (options: { + client: DevnetClient; + from: Address; + portal: Address; + progress: ReturnType; + token: Address; +}): Promise => { + const { client, from, portal, progress, token } = options; + + const isApproved = await client.readContract({ + abi: testMultiTokenAbi, + address: token, + functionName: "isApprovedForAll", + args: [from, portal], + }); + + if (isApproved) { + return undefined; + } + + progress.start(`Approving ERC1155Portal...`); + const { request } = await client.simulateContract({ + abi: testMultiTokenAbi, + account: from, + address: token, + functionName: "setApprovalForAll", + args: [portal, true], + }); + const hash = await client.writeContract(request); + await client.waitForTransactionReceipt({ hash }); + progress.succeed(`Approved ERC1155Portal`); + return hash; +}; diff --git a/apps/cli/src/api/deposit/erc20.ts b/apps/cli/src/api/deposit/erc20.ts new file mode 100644 index 00000000..74684c83 --- /dev/null +++ b/apps/cli/src/api/deposit/erc20.ts @@ -0,0 +1,157 @@ +import chalk from "chalk"; +import { type Address, erc20Abi, formatUnits } from "viem"; +import { + erc20PortalAbi, + erc20PortalAddress, + testFungibleTokenAddress, +} from "../../contracts.js"; +import type { DevnetClient } from "../../wallet.js"; +import { resolveConnection } from "../connection.js"; +import { + type Amount, + applicationLabel, + type DepositOptions, + type DepositResult, + depositSpinner, + InsufficientBalanceError, + parseAmount, +} from "./common.js"; + +export type Erc20Token = { + address: Address; + name: string; + symbol: string; + decimals: number; +}; + +/** + * Read the metadata of an ERC-20 token. + * @param client client connected to the devnet + * @param address address of the token contract + * @returns name, symbol and decimals of the token + */ +export const readErc20Token = async ( + client: DevnetClient, + address: Address, +): Promise => { + const args = { abi: erc20Abi, address } as const; + const symbol = await client.readContract({ + ...args, + functionName: "symbol", + }); + const name = await client.readContract({ ...args, functionName: "name" }); + const decimals = await client.readContract({ + ...args, + functionName: "decimals", + }); + return { address, name, symbol, decimals }; +}; + +export type DepositErc20Options = DepositOptions & { + /** + * Amount to deposit, in the base unit of the token, or in its display unit + * when given as a string. + */ + amount: Amount; + + /** + * Address of the ERC-20 token contract. + * @default the test token deployed to the devnet + */ + token?: Address; +}; + +export type DepositErc20Result = DepositResult & { + /** Amount deposited, in the base unit of the token. */ + amount: bigint; + + /** Token deposited. */ + token: Erc20Token; +}; + +/** + * Deposit ERC-20 tokens to an application running on a local environment. The + * portal is approved to transfer the tokens, if it is not already. + * @param options deposit options + * @returns the deposit made, and the transaction that made it + */ +export const depositErc20 = async ( + options: DepositErc20Options, +): Promise => { + const { execLayerData = "0x", token: address = testFungibleTokenAddress } = + options; + const { application, client, from } = await resolveConnection(options); + + const token = await readErc20Token(client, address); + const { decimals, symbol } = token; + const amount = parseAmount(options.amount, decimals); + + // check balance + const balance = await client.readContract({ + abi: erc20Abi, + address: token.address, + functionName: "balanceOf", + args: [from], + }); + if (balance < amount) { + throw new InsufficientBalanceError(); + } + + // check allowance + const allowance = await client.readContract({ + abi: erc20Abi, + address: token.address, + functionName: "allowance", + args: [from, erc20PortalAddress], + }); + + // for messages + const amountLabel = `${chalk.cyan(formatUnits(amount, decimals))} ${symbol}`; + const progress = depositSpinner(options); + + // approve if needed + let approvalTransactionHash: `0x${string}` | undefined; + if (allowance < amount) { + progress.start(`Approving ${amountLabel}...`); + const { request } = await client.simulateContract({ + abi: erc20Abi, + account: from, + address: token.address, + functionName: "approve", + args: [erc20PortalAddress, amount], + }); + approvalTransactionHash = await client.writeContract(request); + await client.waitForTransactionReceipt({ + hash: approvalTransactionHash, + }); + progress.succeed(`Approved ${amountLabel}`); + } + + // simulate deposit call + const { request } = await client.simulateContract({ + abi: erc20PortalAbi, + account: from, + address: erc20PortalAddress, + functionName: "depositERC20Tokens", + args: [token.address, application, amount, execLayerData], + }); + + // send deposit + progress.start( + `Depositing ${amountLabel} to ${applicationLabel(application)}...`, + ); + const transactionHash = await client.writeContract(request); + await client.waitForTransactionReceipt({ hash: transactionHash }); + progress.succeed( + `Deposited ${amountLabel} to ${applicationLabel(application)}`, + ); + + return { + amount, + application, + approvalTransactionHash, + from, + token, + transactionHash, + }; +}; diff --git a/apps/cli/src/api/deposit/erc721.ts b/apps/cli/src/api/deposit/erc721.ts new file mode 100644 index 00000000..4812ff22 --- /dev/null +++ b/apps/cli/src/api/deposit/erc721.ts @@ -0,0 +1,189 @@ +import chalk from "chalk"; +import { + type Address, + BaseError, + ContractFunctionRevertedError, + erc721Abi, + type Hash, +} from "viem"; +import { + erc721PortalAbi, + erc721PortalAddress, + testNonFungibleTokenAbi, + testNonFungibleTokenAddress, +} from "../../contracts.js"; +import type { DevnetClient } from "../../wallet.js"; +import { resolveConnection } from "../connection.js"; +import { + type Amount, + applicationLabel, + type DepositResult, + depositSpinner, + InsufficientBalanceError, + parseAmount, + type PortalDepositOptions, + TokenNotFoundError, +} from "./common.js"; + +export type Erc721Token = { + address: Address; + name: string; + symbol: string; +}; + +/** + * Read the metadata of an ERC-721 token. + * @param client client connected to the devnet + * @param address address of the token contract + * @returns name and symbol of the token + */ +export const readErc721Token = async ( + client: DevnetClient, + address: Address, +): Promise => { + const args = { abi: erc721Abi, address } as const; + const symbol = await client.readContract({ + ...args, + functionName: "symbol", + }); + const name = await client.readContract({ ...args, functionName: "name" }); + return { address, name, symbol }; +}; + +export type DepositErc721Options = PortalDepositOptions & { + /** ID of the token to deposit. */ + tokenId: Amount; + + /** + * Address of the ERC-721 token contract. + * @default the test NFT deployed to the devnet + */ + token?: Address; +}; + +export type DepositErc721Result = DepositResult & { + /** ID of the token deposited. */ + tokenId: bigint; + + /** Token deposited. */ + token: Erc721Token; +}; + +/** + * Deposit an ERC-721 token to an application running on a local environment. + * The portal is approved to transfer the token, if it is not already. + * @param options deposit options + * @returns the deposit made, and the transaction that made it + */ +export const depositErc721 = async ( + options: DepositErc721Options, +): Promise => { + const { + baseLayerData = "0x", + execLayerData = "0x", + token: address = testNonFungibleTokenAddress, + } = options; + const { application, client, from } = await resolveConnection(options); + + const tokenId = parseAmount(options.tokenId, 0); + const token = await readErc721Token(client, address); + + // the test NFT has a different abi, which allows minting + const tokenAbi = + token.address === testNonFungibleTokenAddress + ? testNonFungibleTokenAbi + : erc721Abi; + + // check ownership + let currentOwner: Address; + try { + currentOwner = await client.readContract({ + abi: tokenAbi, + address: token.address, + args: [tokenId], + functionName: "ownerOf", + }); + } catch (e: unknown) { + if (e instanceof BaseError) { + const revertError = e.walk( + (err) => err instanceof ContractFunctionRevertedError, + ); + if ( + revertError instanceof ContractFunctionRevertedError && + revertError.data?.errorName === "ERC721NonexistentToken" + ) { + throw new TokenNotFoundError(`Token ${tokenId} does not exist`); + } + throw new Error("Failed to check ownership", { cause: e }); + } + throw e; + } + + if (currentOwner !== from) { + throw new InsufficientBalanceError(); + } + + // check allowance + const operator = await client.readContract({ + abi: tokenAbi, + address: token.address, + args: [tokenId], + functionName: "getApproved", + }); + + // for messages + const tokenLabel = `${chalk.cyan(tokenId)} ${token.symbol}`; + const progress = depositSpinner(options); + + // approve if needed + let approvalTransactionHash: Hash | undefined; + if (operator !== erc721PortalAddress) { + progress.start(`Approving ${tokenLabel}...`); + const { request } = await client.simulateContract({ + abi: tokenAbi, + account: from, + address: token.address, + functionName: "approve", + args: [erc721PortalAddress, tokenId], + }); + approvalTransactionHash = await client.writeContract(request); + await client.waitForTransactionReceipt({ + hash: approvalTransactionHash, + }); + progress.succeed(`Approved ${tokenLabel}`); + } + + // simulate deposit call + const { request } = await client.simulateContract({ + abi: erc721PortalAbi, + account: from, + address: erc721PortalAddress, + functionName: "depositERC721Token", + args: [ + token.address, + application, + tokenId, + baseLayerData, + execLayerData, + ], + }); + + // send deposit + progress.start( + `Depositing ${tokenLabel} to ${applicationLabel(application)}...`, + ); + const transactionHash = await client.writeContract(request); + await client.waitForTransactionReceipt({ hash: transactionHash }); + progress.succeed( + `Deposited ${tokenLabel} to ${applicationLabel(application)}`, + ); + + return { + application, + approvalTransactionHash, + from, + token, + tokenId, + transactionHash, + }; +}; diff --git a/apps/cli/src/api/deposit/ether.ts b/apps/cli/src/api/deposit/ether.ts new file mode 100644 index 00000000..bf6c53de --- /dev/null +++ b/apps/cli/src/api/deposit/ether.ts @@ -0,0 +1,69 @@ +import chalk from "chalk"; +import { formatUnits } from "viem"; +import { etherPortalAbi, etherPortalAddress } from "../../contracts.js"; +import { resolveConnection } from "../connection.js"; +import { + type Amount, + applicationLabel, + type DepositOptions, + type DepositResult, + depositSpinner, + InsufficientBalanceError, + parseAmount, +} from "./common.js"; + +export type DepositEtherOptions = DepositOptions & { + /** Amount to deposit, in wei, or in ETH when given as a string. */ + amount: Amount; +}; + +export type DepositEtherResult = DepositResult & { + /** Amount deposited, in wei. */ + amount: bigint; +}; + +/** + * Deposit ether to an application running on a local environment. + * @param options deposit options + * @returns the deposit made, and the transaction that made it + */ +export const depositEther = async ( + options: DepositEtherOptions, +): Promise => { + const { execLayerData = "0x" } = options; + const { application, client, from } = await resolveConnection(options); + + const { decimals, symbol } = client.chain.nativeCurrency; + const amount = parseAmount(options.amount, decimals); + + // check balance + const balance = await client.getBalance({ address: from }); + if (balance < amount) { + throw new InsufficientBalanceError(); + } + + const { request } = await client.simulateContract({ + abi: etherPortalAbi, + account: from, + address: etherPortalAddress, + args: [application, execLayerData], + functionName: "depositEther", + value: amount, + }); + + // for messages + const amountLabel = `${chalk.cyan(formatUnits(amount, decimals))} ${symbol}`; + + // send deposit + const progress = depositSpinner(options); + progress.start( + `Depositing ${amountLabel} to ${applicationLabel(application)}...`, + ); + const transactionHash = await client.writeContract(request); + await client.waitForTransactionReceipt({ hash: transactionHash }); + progress.succeed( + `Deposited ${amountLabel} to ${applicationLabel(application)}`, + ); + + return { amount, application, from, transactionHash }; +}; diff --git a/apps/cli/src/api/deposit/index.ts b/apps/cli/src/api/deposit/index.ts new file mode 100644 index 00000000..f6fad201 --- /dev/null +++ b/apps/cli/src/api/deposit/index.ts @@ -0,0 +1,15 @@ +export { + type Amount, + DepositError, + type DepositOptions, + type DepositResult, + InsufficientBalanceError, + InvalidAmountError, + parseAmount, + type PortalDepositOptions, + TokenNotFoundError, +} from "./common.js"; +export * from "./erc20.js"; +export * from "./erc721.js"; +export * from "./erc1155.js"; +export * from "./ether.js"; diff --git a/apps/cli/src/api/doctor.ts b/apps/cli/src/api/doctor.ts new file mode 100644 index 00000000..3e4ccf16 --- /dev/null +++ b/apps/cli/src/api/doctor.ts @@ -0,0 +1,165 @@ +import { execa } from "execa"; +import semver from "semver"; + +const MINIMUM_DOCKER_VERSION = "25.0.0"; // Replace with our minimum required Docker version +const MINIMUM_DOCKER_COMPOSE_VERSION = "2.24.0"; // Replace with our minimum required Docker Compose version +const MINIMUM_BUILDX_VERSION = "0.13.0"; // Replace with our minimum required Buildx version + +export type DoctorCheck = { + /** Name of the requirement being checked. */ + name: string; + + /** Whether the requirement is satisfied. */ + ok: boolean; + + /** Detail of a satisfied requirement, usually the installed version. */ + detail?: string; + + /** Reason why the requirement is not satisfied. */ + message?: string; +}; + +export type DoctorResult = { + /** Whether all requirements are satisfied. */ + ok: boolean; + + /** Every requirement checked, satisfied or not. */ + checks: DoctorCheck[]; +}; + +type Check = { + name: string; + run: () => Promise; +}; + +const checkDocker = async (): Promise => { + try { + const { stdout: dockerVersion } = await execa("docker", [ + "version", + "--format", + "{{json .Client.Version}}", + ]); + + const v = semver.coerce(dockerVersion); + if (v !== null && !semver.gte(v, MINIMUM_DOCKER_VERSION)) { + throw new Error( + `Unsupported Docker version. Minimum required version is ${MINIMUM_DOCKER_VERSION}. Installed version is ${v}.`, + ); + } + return v?.version; + } catch (e: unknown) { + if ( + e instanceof Error && + (e as NodeJS.ErrnoException).code === "ENOENT" + ) { + throw new Error("Docker not found"); + } + throw e; + } +}; + +const checkCompose = async (): Promise => { + try { + const { stdout: dockerComposeVersion } = await execa("docker", [ + "compose", + "version", + "--short", + ]); + + const v = semver.coerce(dockerComposeVersion); + if (v !== null && !semver.gte(v, MINIMUM_DOCKER_COMPOSE_VERSION)) { + throw new Error( + `Unsupported Docker Compose version. Minimum required version is ${MINIMUM_DOCKER_COMPOSE_VERSION}. Installed version is ${v}.`, + ); + } + return dockerComposeVersion; + } catch (e: unknown) { + if ( + e instanceof Error && + (e as Error & { exitCode?: number }).exitCode === 125 + ) { + throw new Error( + "Docker Compose is required but not installed or the command execution failed. Please refer to the Docker Compose documentation for installation instructions: https://docs.docker.com/compose/install/", + ); + } + throw e; + } +}; + +const checkBuildx = async (): Promise => { + try { + const { stdout: buildxOutput } = await execa("docker", [ + "buildx", + "version", + ]); + + const v = semver.coerce(buildxOutput); + if (v !== null && !semver.gte(v, MINIMUM_BUILDX_VERSION)) { + throw new Error( + `Unsupported Docker Buildx version. Minimum required version is ${MINIMUM_BUILDX_VERSION}. Installed version is ${v}.`, + ); + } + return v?.version; + } catch (e: unknown) { + if ( + e instanceof Error && + (e as Error & { exitCode?: number }).exitCode === 125 + ) { + throw new Error( + "Docker Buildx is required but not installed. Please refer to the Docker Desktop documentation for installation instructions: https://docs.docker.com/desktop/", + ); + } + throw e; + } +}; + +const checkRiscv = async (): Promise => { + const { stdout: platformsOutput } = await execa("docker", [ + "buildx", + "ls", + "--format", + "{{.Platforms}}", + ]); + + const buildxPlatforms: string[] = platformsOutput + .split(",") + .map((platform) => platform.trim()); + + if (!buildxPlatforms.includes("linux/riscv64")) { + throw new Error( + "Your system does not support riscv64 architecture. Run `docker run --privileged --rm tonistiigi/binfmt:riscv` to enable riscv64 support.", + ); + } + return "linux/riscv64"; +}; + +const checks: Check[] = [ + { name: "Docker Engine", run: checkDocker }, + { name: "Docker Compose", run: checkCompose }, + { name: "Docker Buildx", run: checkBuildx }, + { name: "Docker RISC-V support", run: checkRiscv }, +]; + +/** + * Check whether the system has everything needed to build and run Cartesi + * applications. All requirements are checked concurrently, and the result of + * every check is reported, satisfied or not. + * @returns the result of every check + */ +export const doctor = async (): Promise => { + const results = await Promise.all( + checks.map(async ({ name, run }): Promise => { + try { + return { name, ok: true, detail: await run() }; + } catch (e: unknown) { + return { + name, + ok: false, + message: e instanceof Error ? e.message : String(e), + }; + } + }), + ); + + return { ok: results.every((check) => check.ok), checks: results }; +}; diff --git a/apps/cli/src/api/hash.ts b/apps/cli/src/api/hash.ts new file mode 100644 index 00000000..84278e48 --- /dev/null +++ b/apps/cli/src/api/hash.ts @@ -0,0 +1,9 @@ +import type { Hash } from "viem"; +import { getMachineHash } from "../base.js"; + +/** + * Read the template hash of the Cartesi machine snapshot created by + * {@link build}, at `.cartesi/image` of the current working directory. + * @returns the machine template hash, or `undefined` if there is no snapshot + */ +export const hash = async (): Promise => getMachineHash(); diff --git a/apps/cli/src/api/index.ts b/apps/cli/src/api/index.ts new file mode 100644 index 00000000..bbad67f1 --- /dev/null +++ b/apps/cli/src/api/index.ts @@ -0,0 +1,14 @@ +export * from "./address-book.js"; +export * from "./build.js"; +export * from "./clean.js"; +export * from "./connection.js"; +export * from "./create.js"; +export * from "./deposit/index.js"; +export * from "./doctor.js"; +export * from "./hash.js"; +export * from "./logs.js"; +export * from "./run.js"; +export * from "./send.js"; +export * from "./shell.js"; +export * from "./status.js"; +export * from "./types.js"; diff --git a/apps/cli/src/api/logs.ts b/apps/cli/src/api/logs.ts new file mode 100644 index 00000000..73856485 --- /dev/null +++ b/apps/cli/src/api/logs.ts @@ -0,0 +1,98 @@ +import { execa } from "execa"; +import { getProjectName, getServiceInfo } from "../base.js"; + +export type LogsOptions = { + /** + * Name of the project (used by docker compose and cartesi-rollups-node). + * @default basename of the current working directory + */ + projectName?: string; + + /** Follow log output. */ + follow?: boolean; + + /** + * Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative + * (e.g. 42m for 42 minutes). + */ + since?: string; + + /** + * Number of lines to show from the end of the logs. + * @default "all" + */ + tail?: string; + + /** + * Show logs before a timestamp (e.g. 2013-01-02T13:23:37Z) or relative + * (e.g. 42m for 42 minutes). + */ + until?: string; + + /** + * Write the logs directly to the terminal instead of returning them. + * @default false + */ + stream?: boolean; + + /** + * Called for every log line, as they are produced. Useful together with + * `follow`, where collecting the whole output is not an option. + */ + onLine?: (line: string) => void; +}; + +/** + * Read the logs of the rollups node of a local environment. + * + * By default the logs are collected and returned. When `stream` is enabled the + * logs are written directly to the terminal and nothing is returned. Individual + * lines can also be consumed as they are produced by using `onLine`. + * + * @param options logs options + * @returns the collected logs, or `undefined` when `stream` is enabled + */ +export const logs = async ( + options: LogsOptions = {}, +): Promise => { + const { + follow, + onLine, + since, + stream = false, + tail = "all", + until, + } = options; + const projectName = getProjectName(options); + + const logOptions: string[] = []; + if (follow) logOptions.push("--follow"); + if (since) logOptions.push("--since", since); + if (tail) logOptions.push("--tail", tail); + if (until) logOptions.push("--until", until); + + const serviceInfo = await getServiceInfo({ + projectName, + service: "rollups_node", + }); + if (!serviceInfo) { + throw new Error(`service rollups_node not found`); + } + + const args = ["container", "logs", ...logOptions, serviceInfo.ID]; + + if (stream) { + await execa("docker", args, { stdio: "inherit" }); + return undefined; + } + + // interleave stdout and stderr, as docker logs writes to both + const subprocess = execa("docker", args, { all: true }); + if (onLine) { + for await (const line of subprocess.iterable({ from: "all" })) { + onLine(line); + } + } + const { all, stdout } = await subprocess; + return all ?? stdout; +}; diff --git a/apps/cli/src/api/run.ts b/apps/cli/src/api/run.ts new file mode 100644 index 00000000..b1072369 --- /dev/null +++ b/apps/cli/src/api/run.ts @@ -0,0 +1,413 @@ +import chalk from "chalk"; +import getPort, { portNumbers } from "get-port"; +import ora from "ora"; +import { + type Address, + createPublicClient, + type Hex, + http, + numberToHex, +} from "viem"; +import { getMachineHash, getProjectName } from "../base.js"; +import { + DEFAULT_SDK_VERSION, + PREFERRED_PORT, + type WithdrawalConfig, +} from "../config.js"; +import { + deployApplication, + host, + removeApplication, + type RollupsDeployment, + startEnvironment, + stopEnvironment, + waitHealthyEnvironment, +} from "../exec/rollups.js"; +import type { ForkConfig } from "../types/chain.js"; +import { assertForkConfig } from "../validations.js"; +import { + type ConfigOptions, + listrRenderer, + type Progress, + type ProgressOptions, + resolveConfig, +} from "./types.js"; + +export type RunOptions = ConfigOptions & + ProgressOptions & { + /** + * Interval between blocks, in seconds. + * @default 2 + */ + blockTime?: number; + + /** + * Number of blocks between a claim being submitted and accepted + * (Authority/Quorum only). + * @default 0 + */ + claimStagingPeriod?: number; + + /** Number of cpu limits for the rollups-node. */ + cpus?: number; + + /** + * Default block used when fetching new blocks. + * @default "latest" + */ + defaultBlock?: "latest" | "safe" | "pending" | "finalized"; + + /** + * Deploy the application built at `.cartesi/image` after the + * environment is healthy. When there is no machine snapshot the + * environment is started anyway, and no application is deployed. + * @default true + */ + deploy?: boolean; + + /** + * Run the environment in the background. When disabled, the returned + * {@link RunResult.cmd} resolves when the environment terminates. + * @default true + */ + detach?: boolean; + + /** + * Do not start the environment, only resolve the docker compose + * configuration, returned as {@link RunResult.config}. + * @default false + */ + dryRun?: boolean; + + /** + * Length of an epoch, in blocks. + * @default 720 + */ + epochLength?: number; + + /** Block number to fork from. */ + forkBlockNumber?: number; + + /** RPC URL to fork from. */ + forkUrl?: string; + + /** Memory limit for the rollups-node, in MB. */ + memory?: number; + + /** + * Port to listen on. + * @default first free port from 6751 + */ + port?: number; + + /** + * Name of the project (used by docker compose and cartesi-rollups-node). + * @default basename of the current working directory + */ + projectName?: string; + + /** + * Deploy the application with PRT consensus. + * @default false + */ + prt?: boolean; + + /** Version of the Cartesi Rollups Runtime to use. */ + runtimeVersion?: string; + + /** + * Optional services to start, from {@link AVAILABLE_SERVICES}. The + * single value `all` starts every optional service. + * @default [] + */ + services?: string[]; + + /** + * Increase the log level of the environment services. + * @default true when `progress` is "verbose" + */ + verbose?: boolean; + }; + +export type RunResult = { + /** Name of the project the environment was started as. */ + projectName: string; + + /** Port the environment is listening on. */ + port: number; + + /** URL of the environment. */ + url: string; + + /** Current deployment of the application, if any. */ + readonly deployment: RollupsDeployment | undefined; + + /** + * Docker compose configuration of the environment. Only defined when the + * `dryRun` option is enabled, in which case nothing is started. + */ + config?: string; + + /** + * Resolves when the environment terminates. Only defined when the `detach` + * option is disabled. + */ + cmd?: Promise; + + /** + * Deploy the application currently built at `.cartesi/image`, replacing the + * previous deployment, if any. Useful to redeploy after a rebuild. + * @returns the new deployment, or `undefined` if there is no machine snapshot + */ + deploy(): Promise; + + /** Remove the current deployment of the application from the environment. */ + undeploy(): Promise; + + /** Stop the environment, removing its containers and volumes. */ + stop(): Promise; +}; + +/** + * Resolve the configuration of an optional anvil fork. + * @param options fork url and optional block number + * @returns fork configuration, or `undefined` if no fork url was provided + */ +export const configureFork = async (options: { + forkUrl?: string; + forkBlockNumber?: number; +}): Promise => { + if (!options.forkUrl) { + return undefined; + } + + const url = options.forkUrl; + + // create a client to upstream so we can query it + const client = createPublicClient({ + transport: http(url), + }); + + // use explicit fork-block-number or query from upstream + const blockNumber = options.forkBlockNumber + ? BigInt(options.forkBlockNumber) + : await client.getBlockNumber(); + + // need to query fork chainId if forkUrl is specified + const chainId = await client.getChainId(); + + return { blockNumber, chainId, url }; +}; + +const undeployApplication = async (options: { + progress: Progress; + projectName: string; +}) => { + const { progress, projectName } = options; + const spinner = ora({ + isSilent: progress === "silent", + text: `${chalk.cyan(projectName)} undeploying...`, + }).start(); + await removeApplication({ + application: projectName, + force: true, + projectName, + }); + spinner.succeed(`${chalk.cyan(projectName)} undeployed`); +}; + +const deployMachine = async (options: { + claimStagingPeriod: number; + consensus?: Address; + epochLength: number; + hash: Hex; + progress: Progress; + projectName: string; + prt?: boolean; + salt: Hex; + withdrawalConfig?: WithdrawalConfig; +}) => { + const { + claimStagingPeriod, + consensus, + epochLength, + hash, + progress, + projectName, + prt, + salt, + withdrawalConfig, + } = options; + + // deploy application to node (onchain and offchain) + const spinner = ora({ + isSilent: progress === "silent", + text: `deploying ${chalk.cyan(hash)} as ${chalk.cyan(projectName)}`, + }); + + const application = await deployApplication({ + claimStagingPeriod, + consensus, + epochLength, + name: projectName, + projectName, + prt, + salt, + snapshotPath: "/var/lib/cartesi-rollups-node/snapshots/image", + withdrawalConfig, + }); + spinner.succeed( + `${chalk.cyan(projectName)} machine hash is ${chalk.cyan(hash)}`, + ); + spinner.succeed( + `${chalk.cyan(projectName)} contract deployed at ${chalk.cyan(application.address)}`, + ); + return application; +}; + +/** + * Run a local Cartesi node for the application, and deploy to it the machine + * snapshot built at `.cartesi/image`, if there is one. + * + * The environment keeps running in the background until + * {@link RunResult.stop} is called. + * + * @param options run options + * @returns a handle of the running environment + */ +export const run = async (options: RunOptions = {}): Promise => { + const { + blockTime = 2, + claimStagingPeriod = 0, + cpus, + defaultBlock = "latest", + deploy: deployOnStart = true, + detach = true, + dryRun = false, + epochLength = 720, + memory, + progress = "silent", + prt = false, + runtimeVersion = DEFAULT_SDK_VERSION, + services = [], + } = options; + + const verbose = options.verbose ?? progress === "verbose"; + + // project name explicitly defined or the current directory name + const projectName = getProjectName(options); + + // get application configuration (e.g. use withdrawal config if present) + const applicationConfig = resolveConfig(options.config); + + // resolve port number, using the first free port in a range, unless explicitly set + const port = + options.port || + (await getPort({ + port: portNumbers(PREFERRED_PORT, PREFERRED_PORT + 10), + })); + + // host address + const url = `${host}:${port}`; + + // configure optional anvil fork + const forkConfig = await configureFork(options); + + if (forkConfig) { + await assertForkConfig(forkConfig, { includePRT: prt }); + } + + // run compose environment + const { cmd, config } = await startEnvironment({ + blockTime, + cpus, + defaultBlock, + detach, + dryRun, + forkConfig, + memory, + port, + projectName, + prt, + runtimeVersion, + services, + verbose, + }); + + let deployment: RollupsDeployment | undefined; + let salt = 0; + + const stop = async () => { + await stopEnvironment({ projectName }); + }; + + const undeploy = async () => { + if (deployment) { + await undeployApplication({ progress, projectName }); + deployment = undefined; + } + }; + + const deploy = async () => { + const machineHash = await getMachineHash(); + if (!machineHash) { + return undefined; + } + + // keep the consensus of the previous deployment, if there is one + const consensus = deployment?.consensus; + await undeploy(); + + deployment = await deployMachine({ + claimStagingPeriod, + consensus, + epochLength, + hash: machineHash, + progress, + projectName, + prt, + salt: numberToHex(salt++, { size: 32 }), + withdrawalConfig: applicationConfig?.withdrawalConfig, + }); + return deployment; + }; + + const result: RunResult = { + get deployment() { + return deployment; + }, + cmd, + config, + deploy, + port, + projectName, + stop, + undeploy, + url, + }; + + if (dryRun) { + // environment was not started, just return the compose configuration + return result; + } + + ora({ isSilent: progress === "silent" }).succeed( + `${chalk.cyan(projectName)} starting at ${chalk.cyan(url)}`, + ); + + // wait for the environment to be healthy + await waitHealthyEnvironment({ + name: projectName, + port, + projectName, + renderer: listrRenderer(progress), + services, + }); + + // deploy the application built at .cartesi/image, if there is one + if (deployOnStart) { + await deploy(); + } + + return result; +}; diff --git a/apps/cli/src/api/send.ts b/apps/cli/src/api/send.ts new file mode 100644 index 00000000..af36712b --- /dev/null +++ b/apps/cli/src/api/send.ts @@ -0,0 +1,178 @@ +import { + type Address, + encodeAbiParameters, + encodePacked, + getAddress, + type Hash, + type Hex, + isAddress, + isHex, + parseAbiParameters, + stringToHex, +} from "viem"; +import { inputBoxAbi, inputBoxAddress } from "../contracts.js"; +import { type ConnectionOptions, resolveConnection } from "./connection.js"; + +/** Encoding of an application input. */ +export type InputEncoding = "abi" | "abi-packed" | "hex" | "string"; + +export type EncodeInputOptions = { + /** + * Encoding of the input. When not defined, an input starting with `0x` is + * assumed to be hex, and anything else is encoded as an UTF-8 string. + */ + encoding?: InputEncoding; + + /** ABI parameters, required by the `abi` and `abi-packed` encodings. */ + abiParams?: string; +}; + +/** + * Encode an application input as hex, according to the given encoding. + * @param input input payload + * @param options encoding options + * @returns the encoded input, or `undefined` if there is no input + */ +export const encodeInput = async ( + input: string | undefined, + options: EncodeInputOptions, +): Promise => { + const { encoding } = options; + if (input) { + if (encoding === "hex") { + // validate if is a hex value + if (!isHex(input)) { + throw new Error("input encoded as hex must start with 0x"); + } + return input; + } + if (encoding === "string") { + // encode UTF-8 string as hex + return stringToHex(input); + } + if (encoding === "abi" || encoding === "abi-packed") { + const abiParams = options.abiParams; + if (!abiParams) { + throw new Error("Undefined input-abi-params"); + } + const abiParameters = parseAbiParameters(abiParams); + // TODO: decode values + const values = input.split(",").map((v, index) => { + if (index >= abiParameters.length) { + throw new Error( + `Too many values, expected ${abiParameters.length} values based on --input-abi-params '${abiParams}', parsing value at index ${index} from input '${input}'`, + ); + } + const param = abiParameters[index]; + switch (param.type) { + case "string": + return v; + case "bool": + if (v === "true") return true; + if (v === "false") return false; + throw new Error(`Invalid boolean value: ${v}`); + case "uint": + case "uint8": + case "uint16": + case "uint32": + case "uint64": + case "uint128": + case "uint256": + case "int": + case "int8": + case "int16": + case "int32": + case "int64": + case "int128": + case "int256": + try { + return BigInt(v); + } catch { + throw new Error(`Invalid uint value: ${v}`); + } + case "bytes": + if (isHex(v)) { + return v as Hex; + } + throw new Error(`Invalid bytes value: ${v}`); + case "address": + if (isAddress(v)) { + return getAddress(v); + } + throw new Error(`Invalid address value: ${v}`); + default: + throw new Error(`Unsupported type ${param.type}`); + } + }); + if (values.length !== abiParameters.length) { + throw new Error( + `Not enough values, expected ${abiParameters.length} values based on --input-abi-params '${abiParams}', parsed ${values.length} values from input '${input}'`, + ); + } + if (encoding === "abi") { + return encodeAbiParameters(abiParameters, values); + } + const types = abiParameters.map((p) => p.type); + return encodePacked(types, values); + } + if (isHex(input)) { + // encoding not specified, if starts with 0x, assume hex + return input; + } + // encode UTF-8 string as hex + return stringToHex(input); + } + return undefined; +}; + +export type SendOptions = ConnectionOptions & + EncodeInputOptions & { + /** Input payload, encoded according to `encoding`. */ + input?: string; + + /** Input payload already encoded as hex. Takes precedence over `input`. */ + payload?: Hex; + }; + +export type SendResult = { + /** Address of the application the input was sent to. */ + application: Address; + + /** Address of the input sender. */ + from: Address; + + /** Input payload, encoded as hex. */ + payload: Hex; + + /** Hash of the transaction that added the input. */ + transactionHash: Hash; +}; + +/** + * Send an input to an application running on a local environment. + * @param options send options + * @returns the input sent, and the transaction that added it to the input box + */ +export const send = async (options: SendOptions): Promise => { + // encode the payload, unless it is already encoded + const payload = + options.payload ?? (await encodeInput(options.input, options)); + if (!payload) { + throw new Error("Undefined input payload"); + } + + const { application, client, from } = await resolveConnection(options); + + const { request } = await client.simulateContract({ + address: inputBoxAddress, + abi: inputBoxAbi, + account: from, + args: [application, payload], + functionName: "addInput", + }); + + const transactionHash = await client.writeContract(request); + await client.waitForTransactionReceipt({ hash: transactionHash }); + + return { application, from, payload, transactionHash }; +}; diff --git a/apps/cli/src/api/shell.ts b/apps/cli/src/api/shell.ts new file mode 100644 index 00000000..17cf5a35 --- /dev/null +++ b/apps/cli/src/api/shell.ts @@ -0,0 +1,80 @@ +import { ExecaError } from "execa"; +import fs from "fs-extra"; +import path from "node:path"; +import { getContextPath } from "../base.js"; +import { bootMachine } from "../machine.js"; +import { type ConfigOptions, resolveConfig } from "./types.js"; + +export type ShellOptions = ConfigOptions & { + /** + * Shell command to run inside the machine. + * @default "/bin/sh" + */ + command?: string; + + /** + * Run as the root user. + * @default false + */ + runAsRoot?: boolean; +}; + +/** + * Boot the machine of the application in interactive mode, running a shell (or + * any other command) inside it. Requires the drives to be built already, by + * {@link build}. + * + * The machine is attached to the terminal of the calling process. + * + * @param options shell options + */ +export const shell = async (options: ShellOptions = {}): Promise => { + const { command = "/bin/sh", runAsRoot = false } = options; + + // get application configuration, from a Config object or 'cartesi.toml' + const config = resolveConfig(options.config); + + // destination directory for image and intermediate files + const destination = path.resolve(getContextPath()); + + // check if all drives are built + for (const [name, drive] of Object.entries(config.drives)) { + const filename = `${name}.${drive.format}`; + const pathname = getContextPath(filename); + if (!fs.existsSync(pathname)) { + throw new Error(`drive '${name}' not built, run 'build'`); + } + } + + const machine = { + ...config.machine, + + // create shell entrypoint + entrypoint: command, + + // run as root if flag is set + user: runAsRoot ? "root" : undefined, + }; + + // boot machine + try { + await bootMachine( + { ...config, machine }, + undefined, + { interactive: true }, // start with interactive mode on + { + cwd: destination, + stdio: "inherit", + tty: true, + }, + ); + } catch (error: unknown) { + if (error instanceof ExecaError) { + // exit code of a shell terminated by SIGINT, just return gracefully + if (error.exitCode === 130) { + return; + } + } + throw error; + } +}; diff --git a/apps/cli/src/api/status.ts b/apps/cli/src/api/status.ts new file mode 100644 index 00000000..955e78f0 --- /dev/null +++ b/apps/cli/src/api/status.ts @@ -0,0 +1,48 @@ +import { getProjectName, getServiceState } from "../base.js"; +import { getDeployments, type RollupsDeployment } from "../exec/rollups.js"; + +export type StatusOptions = { + /** + * Name of the project (used by docker compose and cartesi-rollups-node). + * @default basename of the current working directory + */ + projectName?: string; +}; + +export type StatusResult = { + /** Name of the project the status refers to. */ + projectName: string; + + /** Whether the rollups node of the environment is running. */ + running: boolean; + + /** State of the rollups node container, as reported by docker compose. */ + state?: string; + + /** Applications deployed to the rollups node. */ + deployments: RollupsDeployment[]; +}; + +/** + * Query the status of a local environment, and the applications deployed to it. + * @param options status options + * @returns state of the environment and its deployments + */ +export const status = async ( + options: StatusOptions = {}, +): Promise => { + const projectName = getProjectName(options); + + const state = await getServiceState({ + projectName, + service: "rollups_node", + }); + const deployments = await getDeployments({ projectName }); + + return { + projectName, + running: state === "running", + state, + deployments, + }; +}; diff --git a/apps/cli/src/api/types.ts b/apps/cli/src/api/types.ts new file mode 100644 index 00000000..3b3fe59c --- /dev/null +++ b/apps/cli/src/api/types.ts @@ -0,0 +1,74 @@ +import { getApplicationConfig } from "../base.js"; +import type { Config } from "../config.js"; + +/** + * Verbosity of the progress information written to the terminal while an API + * function is running. + * + * - `silent`: nothing is written to the terminal (default of the library API); + * - `default`: spinners and task lists, same as the CLI; + * - `verbose`: no spinners, one line per event, same as the CLI `--verbose`. + */ +export type Progress = "silent" | "default" | "verbose"; + +/** + * Options shared by all API functions that report progress. + */ +export type ProgressOptions = { + /** + * How much progress information is written to the terminal. + * @default "silent" + */ + progress?: Progress; +}; + +/** + * Application configuration, either already parsed, or a path (or list of + * paths) of TOML configuration files to be read and merged, in order. + */ +export type ConfigInput = Config | string | string[]; + +export type ConfigOptions = { + /** + * Application configuration, or path of the configuration file(s). + * @default "cartesi.toml" + */ + config?: ConfigInput; +}; + +/** + * Resolve the application configuration from the several ways it can be + * provided to the API: an already parsed {@link Config}, one configuration file + * path, a list of configuration file paths, or nothing (which falls back to + * `cartesi.toml` of the current directory). + * @param config configuration or path of configuration file(s) + * @returns parsed application configuration + */ +export const resolveConfig = (config?: ConfigInput): Config => { + if (config === undefined) { + return getApplicationConfig(["cartesi.toml"]); + } + if (typeof config === "string") { + return getApplicationConfig([config]); + } + if (Array.isArray(config)) { + return getApplicationConfig(config); + } + return config; +}; + +/** + * Map a {@link Progress} value to a listr2 renderer name. + * @param progress progress verbosity + * @returns name of the listr2 renderer + */ +export const listrRenderer = (progress: Progress = "silent") => { + switch (progress) { + case "silent": + return "silent" as const; + case "verbose": + return "verbose" as const; + case "default": + return "default" as const; + } +}; diff --git a/apps/cli/src/base.ts b/apps/cli/src/base.ts index f567856b..0a7421d5 100644 --- a/apps/cli/src/base.ts +++ b/apps/cli/src/base.ts @@ -28,7 +28,7 @@ import { testNonFungibleTokenAddress, testUsdWithdrawalOutputBuilderAddress, } from "./contracts.js"; -import { cartesiMachineStoredHash } from "./exec"; +import { cartesiMachineStoredHash } from "./exec/index.js"; import { getApplicationAddress, getForkConfig } from "./exec/rollups.js"; import type { PsResponse } from "./types/docker.js"; import { assertForkConfig } from "./validations.js"; diff --git a/apps/cli/src/commands/address-book.ts b/apps/cli/src/commands/address-book.ts index c979f665..a9b9b9ae 100755 --- a/apps/cli/src/commands/address-book.ts +++ b/apps/cli/src/commands/address-book.ts @@ -1,6 +1,6 @@ import { Command } from "@commander-js/extra-typings"; import Table from "cli-table3"; -import { getAddressBook, getProjectName } from "../base.js"; +import { addressBook as getAddressBook } from "../api/address-book.js"; export const createAddressBookCommand = () => { return new Command("address-book") @@ -18,8 +18,7 @@ export const createAddressBookCommand = () => { ) .action(async (contract, options, command) => { const { json } = options; - const projectName = getProjectName(options); - const addressBook = await getAddressBook({ projectName }); + const addressBook = await getAddressBook(options); if (contract !== undefined) { // look up a single contract by name (case-insensitive) diff --git a/apps/cli/src/commands/build.ts b/apps/cli/src/commands/build.ts index fa6cdde9..166e7f13 100755 --- a/apps/cli/src/commands/build.ts +++ b/apps/cli/src/commands/build.ts @@ -1,83 +1,5 @@ import { Command, Option } from "@commander-js/extra-typings"; -import chalk from "chalk"; -import fs from "fs-extra"; -import { Listr, type ListrTask } from "listr2"; -import path from "node:path"; -import tmp from "tmp"; -import { getApplicationConfig, getContextPath } from "../base.js"; -import { - buildDirectory, - buildDocker, - buildEmpty, - buildNone, - buildTar, -} from "../builder/index.js"; -import type { Config, DriveConfig, ImageInfo } from "../config.js"; -import { bootMachine } from "../machine.js"; - -// context for Listr build tasks -interface BuildContext { - config: Config; - debug: boolean; - destination: string; - imageInfo?: ImageInfo; -} - -const buildDriveTask = ( - name: string, - drive: DriveConfig, -): ListrTask => ({ - title: `Building drive ${chalk.cyan(name)}`, - task: async (ctx, task) => { - const { config, debug, destination } = ctx; - const sdk = config.sdk; - const reporter = (line: string) => { - task.output = line; - }; - - switch (drive.builder) { - case "directory": { - await buildDirectory( - name, - drive, - sdk, - destination, - debug, - reporter, - ); - break; - } - case "docker": { - const imageInfo = await buildDocker( - name, - drive, - sdk, - destination, - debug, - reporter, - ); - if (imageInfo && name === "root") { - // only set image info for root drive - ctx.imageInfo = imageInfo; - } - break; - } - case "empty": { - await buildEmpty(name, drive, sdk, destination); - break; - } - case "tar": { - await buildTar(name, drive, sdk, destination, reporter); - break; - } - case "none": { - await buildNone(name, drive, destination); - break; - } - } - task.title = `Build drive ${chalk.cyan(name)}`; - }, -}); +import { build } from "../api/build.js"; export const createBuildCommand = () => { return new Command("build") @@ -101,73 +23,13 @@ export const createBuildCommand = () => { .option("-d, --drives-only", "only build drives, do not boot machine") .option("-v, --verbose", "verbose output", false) .action(async (options) => { - const { debug, drivesOnly, verbose } = options; - - // clean up temp files we create along the process - tmp.setGracefulCleanup(); - - // get application configuration from 'cartesi.toml' - const config = getApplicationConfig(options.config); - - // destination directory for image and intermediate files - const destination = path.resolve(getContextPath()); - - // prepare context directory - await fs.emptyDir(destination); // XXX: make it less error prone + const { config, debug, drivesOnly, verbose } = options; - // build context - const ctx = { + await build({ config, debug, - destination, - verbose, - imageInfo: undefined, - }; - - // tasks to build drives - const driveTasks = Object.entries(config.drives).map( - ([name, drive]) => buildDriveTask(name, drive), - ); - - const builds = new Listr( - [ - { - title: "Build drives", - task: async (_ctx, task) => { - return task.newListr(driveTasks, { - concurrent: true, - rendererOptions: { - collapseSubtasks: false, - }, - ctx, - }); - }, - }, - ], - { ctx, renderer: verbose ? "verbose" : "default" }, - ); - const result = await builds.run(); - - // if only build drives, quit here - if (drivesOnly) { - return; - } - - // create machine snapshot - await bootMachine( - config, - result.imageInfo, - { - finalHash: true, - store: "image", - }, - { - cwd: destination, - stdio: "inherit", - }, - ); - - // make snapshot readable by all users, because cartesi-machine sets to 600 - await fs.chmod(path.join(destination, "image"), 0o755); + drivesOnly, + progress: verbose ? "verbose" : "default", + }); }); }; diff --git a/apps/cli/src/commands/clean.ts b/apps/cli/src/commands/clean.ts index 71bd0824..69790592 100755 --- a/apps/cli/src/commands/clean.ts +++ b/apps/cli/src/commands/clean.ts @@ -1,11 +1,10 @@ import { Command } from "@commander-js/extra-typings"; -import fs from "fs-extra"; -import { getContextPath } from "../base.js"; +import { clean } from "../api/clean.js"; export const createCleanCommand = () => { return new Command("clean") .description("Deletes all cached build artifacts of application.") .action(async () => { - await fs.emptyDir(getContextPath()); + await clean(); }); }; diff --git a/apps/cli/src/commands/create.ts b/apps/cli/src/commands/create.ts index 606230b7..87bd5ebe 100755 --- a/apps/cli/src/commands/create.ts +++ b/apps/cli/src/commands/create.ts @@ -1,22 +1,7 @@ import { Command, Option } from "@commander-js/extra-typings"; import chalk from "chalk"; import ora from "ora"; -import { download } from "../template.js"; - -export const DEFAULT_TEMPLATES_BRANCH = "prerelease/sdk-12"; - -const TEMPLATES = [ - "cpp", - "cpp-low-level", - "go", - "java", - "javascript", - "lua", - "python", - "ruby", - "rust", - "typescript", -] as const; +import { create, DEFAULT_TEMPLATES_BRANCH, TEMPLATES } from "../api/create.js"; export const createCreateCommand = () => { return new Command("create") @@ -34,7 +19,7 @@ export const createCreateCommand = () => { .action(async (name, { branch, template }) => { const spinner = ora("Creating application...").start(); try { - const { dir } = await download(template, branch, name); + const { dir } = await create({ branch, name, template }); spinner.succeed(`Application created at ${chalk.cyan(dir)}`); } catch (e: unknown) { spinner.fail( diff --git a/apps/cli/src/commands/deposit/erc1155.ts b/apps/cli/src/commands/deposit/erc1155.ts index eefb4542..a3c07710 100644 --- a/apps/cli/src/commands/deposit/erc1155.ts +++ b/apps/cli/src/commands/deposit/erc1155.ts @@ -1,17 +1,12 @@ import { Command } from "@commander-js/extra-typings"; import input from "@inquirer/input"; -import chalk from "chalk"; -import ora from "ora"; import { type Address, getAddress, isAddress, isHex } from "viem"; -import { getProjectName } from "../../base.js"; import { - erc1155BatchPortalAbi, - erc1155BatchPortalAddress, - erc1155SinglePortalAbi, - erc1155SinglePortalAddress, - testMultiTokenAbi, - testMultiTokenAddress, -} from "../../contracts.js"; + depositErc1155, + depositErc1155Batch, +} from "../../api/deposit/erc1155.js"; +import { getProjectName } from "../../base.js"; +import { testMultiTokenAddress } from "../../contracts.js"; import { addressInput, bigintInput, @@ -19,25 +14,18 @@ import { } from "../../prompts.js"; import { connect } from "../../wallet.js"; import type { DepositCommandOpts } from "../deposit.js"; +import { reportDepositError } from "./error.js"; -type ERC1155Token = { - address: Address; - name: string; -}; - -const parseToken = async (options: { - token?: string; -}): Promise => { - const address = - options.token && isAddress(options.token) - ? getAddress(options.token) - : await addressInput({ - message: "Token address", - default: testMultiTokenAddress, - }); +const parseTokenAddress = async (token?: string): Promise
=> + token && isAddress(token) + ? getAddress(token) + : addressInput({ + message: "Token address", + default: testMultiTokenAddress, + }); - return { address, name: "TestMultiToken" }; -}; +const parseBigints = (value: string): bigint[] => + value.split(",").map((v) => BigInt(v.trim())); export const createErc1155SingleCommand = () => { return new Command<[], Record, DepositCommandOpts>( @@ -54,11 +42,9 @@ export const createErc1155SingleCommand = () => { const { from } = command.optsWithGlobals(); // connect to anvil - const testClient = await connect(command.optsWithGlobals()); + const client = await connect(command.optsWithGlobals()); - const token = await parseToken({ - token: options.token, - }); + const token = await parseTokenAddress(options.token); const tokenId = tokenIdString ? BigInt(tokenIdString) @@ -75,7 +61,7 @@ export const createErc1155SingleCommand = () => { const account = from && isAddress(from) ? getAddress(from) - : (await testClient.getAddresses())[0]; + : (await client.getAddresses())[0]; const amount = amountString ? BigInt(amountString) @@ -84,18 +70,6 @@ export const createErc1155SingleCommand = () => { decimals: 0, }); - // ensure amount is positive - if (amount <= BigInt(0)) { - console.error( - chalk.red( - "Amount of tokens to be deposited, must be greater than zero.", - ), - ); - return; - } - - const tokenAbi = testMultiTokenAbi; - const baseLayerData = isHex(options.baseLayerData) ? options.baseLayerData : "0x"; @@ -103,72 +77,22 @@ export const createErc1155SingleCommand = () => { ? options.execLayerData : "0x"; - // progress spinner - const progress = ora(); - - // check balance - const balance = await testClient.readContract({ - abi: tokenAbi, - address: token.address, - functionName: "balanceOf", - args: [account, tokenId], - }); - if (balance < amount) { - progress.fail("Insufficient balance"); - return; - } - - // check if sufficiently approved - const isApproved = await testClient.readContract({ - abi: tokenAbi, - address: token.address, - functionName: "isApprovedForAll", - args: [account, erc1155SinglePortalAddress], - }); - - // approve if needed - if (isApproved === false) { - progress.start(`Approving ERC1155Portal...`); - const { request } = await testClient.simulateContract({ - abi: tokenAbi, - account, - address: token.address, - functionName: "setApprovalForAll", - args: [erc1155SinglePortalAddress, true], - }); - const hash = await testClient.writeContract(request); - await testClient.waitForTransactionReceipt({ hash }); - progress.succeed(`Approved ERC1155Portal`); - } - - // simulate deposit call - const { request } = await testClient.simulateContract({ - abi: erc1155SinglePortalAbi, - account, - address: erc1155SinglePortalAddress, - functionName: "depositSingleERC1155Token", - args: [ - token.address, - application, - tokenId, + try { + await depositErc1155({ amount, + application, baseLayerData, + client, execLayerData, - ], - }); - - // for messages - const amountLabel = `${chalk.cyan(amount)} units of token id ${tokenId}`; - - // send deposit - progress.start( - `Depositing ${amountLabel} to ${chalk.cyan(application)}...`, - ); - const hash = await testClient.writeContract(request); - await testClient.waitForTransactionReceipt({ hash }); - progress.succeed( - `Deposited ${amountLabel} to ${chalk.cyan(application)}`, - ); + from: account, + progress: "default", + projectName, + token, + tokenId, + }); + } catch (e: unknown) { + reportDepositError(e); + } }); }; @@ -187,59 +111,19 @@ export const createErc1155BatchCommand = () => { const { from } = command.optsWithGlobals(); // connect to anvil - const testClient = await connect(command.optsWithGlobals()); - - const token = await parseToken({ - token: options.token, - }); + const client = await connect(command.optsWithGlobals()); - // Parse token IDs - let tokenIds: bigint[]; + const token = await parseTokenAddress(options.token); - if (tokenIdsString) { - tokenIds = tokenIdsString - .split(",") - .map((id) => BigInt(id.trim())); - } else { - // Prompt for token IDs if not provided - const user_input = await input({ - message: "Token IDs (comma separated)", - }); - tokenIds = user_input.split(",").map((id) => BigInt(id.trim())); - } - - // Parse amounts - let amounts: bigint[]; - - if (amountsString) { - amounts = amountsString.split(",").map((a) => BigInt(a.trim())); - } else { - // Prompt for amounts if not provided - const user_input = await input({ - message: "Amounts (comma separated)", - }); - amounts = user_input.split(",").map((a) => BigInt(a.trim())); - } - - if (tokenIds.length !== amounts.length) { - console.error( - chalk.red( - "Token IDs and amounts must have the same length.", - ), - ); - return; - } + const tokenIds = parseBigints( + tokenIdsString ?? + (await input({ message: "Token IDs (comma separated)" })), + ); - for (let i = 0; i < amounts.length; i++) { - if (amounts[i] <= BigInt(0)) { - console.error( - chalk.red( - `Amount of token Id: ${tokenIds[i]} to be deposited, must be greater than zero.`, - ), - ); - return; - } - } + const amounts = parseBigints( + amountsString ?? + (await input({ message: "Amounts (comma separated)" })), + ); const projectName = getProjectName(command.optsWithGlobals()); @@ -253,9 +137,7 @@ export const createErc1155BatchCommand = () => { const account = from && isAddress(from) ? getAddress(from) - : (await testClient.getAddresses())[0]; - - const tokenAbi = testMultiTokenAbi; + : (await client.getAddresses())[0]; const baseLayerData = isHex(options.baseLayerData) ? options.baseLayerData @@ -264,80 +146,21 @@ export const createErc1155BatchCommand = () => { ? options.execLayerData : "0x"; - // progress spinner - const progress = ora(); - - // check balances - for (let i = 0; i < tokenIds.length; i++) { - const balance = await testClient.readContract({ - abi: tokenAbi, - address: token.address, - functionName: "balanceOf", - args: [account, tokenIds[i]], - }); - if (balance < amounts[i]) { - progress.fail( - `Insufficient balance for token ID ${tokenIds[i]}`, - ); - return; - } - } - - // check if sufficiently approved - const isApproved = await testClient.readContract({ - abi: tokenAbi, - address: token.address, - functionName: "isApprovedForAll", - args: [account, erc1155BatchPortalAddress], - }); - - // approve if needed - if (isApproved === false) { - progress.start(`Approving ERC1155Portal...`); - const { request } = await testClient.simulateContract({ - abi: tokenAbi, - account, - address: token.address, - functionName: "setApprovalForAll", - args: [erc1155BatchPortalAddress, true], - }); - const hash = await testClient.writeContract(request); - await testClient.waitForTransactionReceipt({ hash }); - progress.succeed(`Approved ERC1155Portal`); - } - - // simulate batch deposit call - const { request } = await testClient.simulateContract({ - abi: erc1155BatchPortalAbi, - account, - address: erc1155BatchPortalAddress, - functionName: "depositBatchERC1155Token", - args: [ - token.address, - application, - tokenIds, + try { + await depositErc1155Batch({ amounts, + application, baseLayerData, + client, execLayerData, - ], - }); - - // for messages - const amountLabel = tokenIds - .map( - (id, i) => - `${chalk.cyan(amounts[i])} units of token id ${id}`, - ) - .join(", "); - - // send deposit - progress.start( - `Depositing tokens to ${chalk.cyan(application)}...`, - ); - const hash = await testClient.writeContract(request); - await testClient.waitForTransactionReceipt({ hash }); - progress.succeed( - `Deposited ${amountLabel} to ${chalk.cyan(application)}`, - ); + from: account, + progress: "default", + projectName, + token, + tokenIds, + }); + } catch (e: unknown) { + reportDepositError(e); + } }); }; diff --git a/apps/cli/src/commands/deposit/erc20.ts b/apps/cli/src/commands/deposit/erc20.ts index 6ced70d4..e0eb3b27 100755 --- a/apps/cli/src/commands/deposit/erc20.ts +++ b/apps/cli/src/commands/deposit/erc20.ts @@ -1,22 +1,8 @@ import { Command } from "@commander-js/extra-typings"; -import chalk from "chalk"; -import ora from "ora"; -import { - type Address, - type PublicClient, - erc20Abi, - formatUnits, - getAddress, - isAddress, - isHex, - parseUnits, -} from "viem"; +import { type Address, getAddress, isAddress, isHex } from "viem"; +import { depositErc20, readErc20Token } from "../../api/deposit/erc20.js"; import { getProjectName } from "../../base.js"; -import { - erc20PortalAbi, - erc20PortalAddress, - testFungibleTokenAddress, -} from "../../contracts.js"; +import { testFungibleTokenAddress } from "../../contracts.js"; import { addressInput, bigintInput, @@ -24,55 +10,15 @@ import { } from "../../prompts.js"; import { connect } from "../../wallet.js"; import type { DepositCommandOpts } from "../deposit.js"; +import { reportDepositError } from "./error.js"; -type ERC20Token = { - address: Address; - name: string; - symbol: string; - decimals: number; -}; - -const readToken = async ( - publicClient: PublicClient, - address: Address, -): Promise => { - const args = { abi: erc20Abi, address }; - const symbol = await publicClient.readContract({ - ...args, - functionName: "symbol", - }); - const name = await publicClient.readContract({ - ...args, - functionName: "name", - }); - const decimals = await publicClient.readContract({ - ...args, - functionName: "decimals", - }); - return { - address, - name, - symbol, - decimals, - }; -}; - -const parseToken = async (options: { - testClient: PublicClient; - token?: string; -}): Promise => { - const { testClient } = options; - - const address = - options.token && isAddress(options.token) - ? getAddress(options.token) - : await addressInput({ - message: "Token address", - default: testFungibleTokenAddress, - }); - - return readToken(testClient, address); -}; +const parseTokenAddress = async (token?: string): Promise
=> + token && isAddress(token) + ? getAddress(token) + : addressInput({ + message: "Token address", + default: testFungibleTokenAddress, + }); export const createErc20Command = () => { return new Command<[], Record, DepositCommandOpts>("erc20") @@ -87,18 +33,16 @@ export const createErc20Command = () => { const projectName = getProjectName(command.optsWithGlobals()); // connect to anvil - const testClient = await connect(command.optsWithGlobals()); + const client = await connect(command.optsWithGlobals()); // the input sender, impersonated const account = from && isAddress(from) ? getAddress(from) - : (await testClient.getAddresses())[0]; + : (await client.getAddresses())[0]; - const token = await parseToken({ - testClient, - token: options.token, - }); + const tokenAddress = await parseTokenAddress(options.token); + const token = await readErc20Token(client, tokenAddress); // get dapp address from local node, or ask const application = await getInputApplicationAddress({ @@ -108,7 +52,7 @@ export const createErc20Command = () => { const { decimals, symbol } = token; const amount = amountStr - ? parseUnits(amountStr, decimals) + ? amountStr : await bigintInput({ message: `Amount (${symbol})`, decimals, @@ -118,64 +62,19 @@ export const createErc20Command = () => { ? options.execLayerData : "0x"; - // progress spinner - const progress = ora(); - - // check balance - const balance = await testClient.readContract({ - abi: erc20Abi, - address: token.address, - functionName: "balanceOf", - args: [account], - }); - if (balance < amount) { - progress.fail("Insufficient balance"); - return; - } - - // check allowance - const allowance = await testClient.readContract({ - abi: erc20Abi, - address: token.address, - functionName: "allowance", - args: [account, erc20PortalAddress], - }); - - // for messages - const amountLabel = `${chalk.cyan(formatUnits(amount, decimals))} ${symbol}`; - - // approve if needed - if (allowance < amount) { - progress.start(`Approving ${amountLabel}...`); - const { request } = await testClient.simulateContract({ - abi: erc20Abi, - account, - address: token.address, - functionName: "approve", - args: [erc20PortalAddress, amount], + try { + await depositErc20({ + amount, + application, + client, + execLayerData, + from: account, + progress: "default", + projectName, + token: token.address, }); - const hash = await testClient.writeContract(request); - await testClient.waitForTransactionReceipt({ hash }); - progress.succeed(`Approved ${amountLabel}`); + } catch (e: unknown) { + reportDepositError(e); } - - // simulate deposit call - const { request } = await testClient.simulateContract({ - abi: erc20PortalAbi, - account, - address: erc20PortalAddress, - functionName: "depositERC20Tokens", - args: [token.address, application, amount, execLayerData], - }); - - // send deposit - progress.start( - `Depositing ${amountLabel} to ${chalk.cyan(application)}...`, - ); - const hash = await testClient.writeContract(request); - await testClient.waitForTransactionReceipt({ hash }); - progress.succeed( - `Deposited ${amountLabel} to ${chalk.cyan(application)}`, - ); }); }; diff --git a/apps/cli/src/commands/deposit/erc721.ts b/apps/cli/src/commands/deposit/erc721.ts index 53567bb8..0739d0d7 100755 --- a/apps/cli/src/commands/deposit/erc721.ts +++ b/apps/cli/src/commands/deposit/erc721.ts @@ -1,23 +1,8 @@ import { Command } from "@commander-js/extra-typings"; -import chalk from "chalk"; -import ora from "ora"; -import { - type Address, - BaseError, - ContractFunctionRevertedError, - type PublicClient, - erc721Abi, - getAddress, - isAddress, - isHex, -} from "viem"; +import { type Address, getAddress, isAddress, isHex } from "viem"; +import { depositErc721 } from "../../api/deposit/erc721.js"; import { getProjectName } from "../../base.js"; -import { - erc721PortalAbi, - erc721PortalAddress, - testNonFungibleTokenAbi, - testNonFungibleTokenAddress, -} from "../../contracts.js"; +import { testNonFungibleTokenAddress } from "../../contracts.js"; import { addressInput, bigintInput, @@ -25,49 +10,15 @@ import { } from "../../prompts.js"; import { connect } from "../../wallet.js"; import type { DepositCommandOpts } from "../deposit.js"; +import { reportDepositError } from "./error.js"; -type ERC721Token = { - address: Address; - name: string; - symbol: string; -}; - -const readToken = async ( - publicClient: PublicClient, - address: Address, -): Promise => { - const args = { abi: erc721Abi, address }; - const symbol = await publicClient.readContract({ - ...args, - functionName: "symbol", - }); - const name = await publicClient.readContract({ - ...args, - functionName: "name", - }); - return { - address, - name, - symbol, - }; -}; - -const parseToken = async (options: { - testClient: PublicClient; - token?: string; -}): Promise => { - const { testClient } = options; - - const address = - options.token && isAddress(options.token) - ? getAddress(options.token) - : await addressInput({ - message: "Token address", - default: testNonFungibleTokenAddress, - }); - - return readToken(testClient, address); -}; +const parseTokenAddress = async (token?: string): Promise
=> + token && isAddress(token) + ? getAddress(token) + : addressInput({ + message: "Token address", + default: testNonFungibleTokenAddress, + }); export const createErc721Command = () => { return new Command<[], Record, DepositCommandOpts>("erc721") @@ -87,22 +38,15 @@ export const createErc721Command = () => { const projectName = getProjectName(command.optsWithGlobals()); // connect to anvil - const testClient = await connect(command.optsWithGlobals()); + const client = await connect(command.optsWithGlobals()); // the input sender, impersonated const account = from && isAddress(from) ? getAddress(from) - : (await testClient.getAddresses())[0]; + : (await client.getAddresses())[0]; - const token = await parseToken({ - testClient, - token: options.token, - }); - const tokenAbi = - token.address === testNonFungibleTokenAddress - ? testNonFungibleTokenAbi - : erc721Abi; + const token = await parseTokenAddress(options.token); // get dapp address from local node, or ask const application = await getInputApplicationAddress({ @@ -110,8 +54,6 @@ export const createErc721Command = () => { projectName, }); - const { symbol } = token; - const baseLayerData = isHex(options.baseLayerData) ? options.baseLayerData : "0x"; @@ -119,87 +61,20 @@ export const createErc721Command = () => { ? options.execLayerData : "0x"; - // progress spinner - const progress = ora(); - - // check balance try { - const currentOwner = await testClient.readContract({ - abi: tokenAbi, - address: token.address, - args: [tokenId], - functionName: "ownerOf", - }); - - if (currentOwner !== account) { - progress.fail("Insufficient balance"); - return; - } - } catch (e: unknown) { - if (e instanceof BaseError) { - const revertError = e.walk( - (err) => err instanceof ContractFunctionRevertedError, - ); - if (revertError instanceof ContractFunctionRevertedError) { - const errorName = revertError.data?.errorName ?? ""; - if (errorName === "ERC721NonexistentToken") { - progress.fail(`Token ${tokenIdStr} does not exist`); - return; - } - } - progress.fail("Failed to check ownership"); - } - } - - // check allowance - const operator = await testClient.readContract({ - abi: tokenAbi, - address: token.address, - args: [tokenId], - functionName: "getApproved", - }); - - // for messages - const amountStr = `${chalk.cyan(tokenIdStr)} ${symbol}`; - - // approve if needed - if (operator !== erc721PortalAddress) { - progress.start(`Approving ${amountStr}...`); - const { request } = await testClient.simulateContract({ - abi: tokenAbi, - account, - address: token.address, - functionName: "approve", - args: [erc721PortalAddress, tokenId], - }); - const hash = await testClient.writeContract(request); - await testClient.waitForTransactionReceipt({ hash }); - progress.succeed(`Approved ${amountStr}`); - } - - // simulate deposit call - const { request } = await testClient.simulateContract({ - abi: erc721PortalAbi, - account, - address: erc721PortalAddress, - functionName: "depositERC721Token", - args: [ - token.address, + await depositErc721({ application, - tokenId, baseLayerData, + client, execLayerData, - ], - }); - - // send deposit - progress.start( - `Depositing ${amountStr} to ${chalk.cyan(application)}...`, - ); - const hash = await testClient.writeContract(request); - await testClient.waitForTransactionReceipt({ hash }); - progress.succeed( - `Deposited ${amountStr} to ${chalk.cyan(application)}`, - ); + from: account, + progress: "default", + projectName, + token, + tokenId, + }); + } catch (e: unknown) { + reportDepositError(e); + } }); }; diff --git a/apps/cli/src/commands/deposit/error.ts b/apps/cli/src/commands/deposit/error.ts new file mode 100644 index 00000000..0975a854 --- /dev/null +++ b/apps/cli/src/commands/deposit/error.ts @@ -0,0 +1,15 @@ +import ora from "ora"; +import { DepositError } from "../../api/deposit/common.js"; + +/** + * Report a deposit that could not be made because of the asset, the amount or + * the balance of the sender. Any other error is rethrown. + * @param e error thrown by a deposit + */ +export const reportDepositError = (e: unknown): void => { + if (e instanceof DepositError) { + ora().fail(e.message); + return; + } + throw e; +}; diff --git a/apps/cli/src/commands/deposit/ether.ts b/apps/cli/src/commands/deposit/ether.ts index 2b0bf6c7..f3b33c91 100755 --- a/apps/cli/src/commands/deposit/ether.ts +++ b/apps/cli/src/commands/deposit/ether.ts @@ -1,12 +1,11 @@ import { Command } from "@commander-js/extra-typings"; -import chalk from "chalk"; -import ora from "ora"; -import { formatUnits, getAddress, isAddress, isHex, parseUnits } from "viem"; +import { getAddress, isAddress, isHex } from "viem"; +import { depositEther } from "../../api/deposit/ether.js"; import { getProjectName } from "../../base.js"; -import { etherPortalAbi, etherPortalAddress } from "../../contracts.js"; import { bigintInput, getInputApplicationAddress } from "../../prompts.js"; import { connect } from "../../wallet.js"; import type { DepositCommandOpts } from "../deposit.js"; +import { reportDepositError } from "./error.js"; export const createEtherCommand = () => { return new Command<[], Record, DepositCommandOpts>("ether") @@ -20,13 +19,13 @@ export const createEtherCommand = () => { const projectName = getProjectName(command.optsWithGlobals()); // connect to anvil - const testClient = await connect(command.optsWithGlobals()); + const client = await connect(command.optsWithGlobals()); // the input sender, impersonated const account = from && isAddress(from) ? getAddress(from) - : (await testClient.getAddresses())[0]; + : (await client.getAddresses())[0]; // get dapp address from local node, or ask const application = await getInputApplicationAddress({ @@ -34,9 +33,9 @@ export const createEtherCommand = () => { projectName, }); - const { decimals, symbol } = testClient.chain.nativeCurrency; + const { decimals, symbol } = client.chain.nativeCurrency; const amount = amountStr - ? parseUnits(amountStr, decimals) + ? amountStr : await bigintInput({ message: `Amount (${symbol})`, decimals, @@ -46,38 +45,18 @@ export const createEtherCommand = () => { ? options.execLayerData : "0x"; - // progress spinner - const progress = ora(); - - // for messages - const amountLabel = `${chalk.cyan(formatUnits(amount, decimals))} ${symbol}`; - - // check balance - const balance = await testClient.getBalance({ - address: account, - }); - if (balance < amount) { - progress.fail("Insufficient balance"); - return; + try { + await depositEther({ + amount, + application, + client, + execLayerData, + from: account, + progress: "default", + projectName, + }); + } catch (e: unknown) { + reportDepositError(e); } - - const { request } = await testClient.simulateContract({ - abi: etherPortalAbi, - account, - address: etherPortalAddress, - args: [application, execLayerData], - functionName: "depositEther", - value: amount, - }); - - // send deposit - progress.start( - `Depositing ${amountLabel} to ${chalk.cyan(application)}...`, - ); - const hash = await testClient.writeContract(request); - await testClient.waitForTransactionReceipt({ hash }); - progress.succeed( - `Deposited ${amountLabel} to ${chalk.cyan(application)}`, - ); }); }; diff --git a/apps/cli/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts index bb3bb951..435c0130 100755 --- a/apps/cli/src/commands/doctor.ts +++ b/apps/cli/src/commands/doctor.ts @@ -1,135 +1,29 @@ import { Command } from "@commander-js/extra-typings"; import chalk from "chalk"; -import { execa } from "execa"; -import ora, { type Ora } from "ora"; -import semver from "semver"; - -const MINIMUM_DOCKER_VERSION = "25.0.0"; // Replace with our minimum required Docker version -const MINIMUM_DOCKER_COMPOSE_VERSION = "2.24.0"; // Replace with our minimum required Docker Compose version -const MINIMUM_BUILDX_VERSION = "0.13.0"; // Replace with our minimum required Buildx version - -const checkDocker = async (progress: Ora): Promise => { - try { - progress.start("Checking Docker Engine version..."); - const { stdout: dockerVersion } = await execa("docker", [ - "version", - "--format", - "{{json .Client.Version}}", - ]); - - const v = semver.coerce(dockerVersion); - if (v !== null && !semver.gte(v, MINIMUM_DOCKER_VERSION)) { - throw new Error( - `Unsupported Docker version. Minimum required version is ${MINIMUM_DOCKER_VERSION}. Installed version is ${v}.`, - ); - } - progress.succeed(`Docker Engine ${chalk.cyan(v)}`); - } catch (e: unknown) { - if ( - e instanceof Error && - (e as NodeJS.ErrnoException).code === "ENOENT" - ) { - throw new Error("Docker not found"); - } - throw e; - } - - return true; -}; - -const checkCompose = async (progress: Ora): Promise => { - try { - progress.start("Checking Docker Compose version..."); - const { stdout: dockerComposeVersion } = await execa("docker", [ - "compose", - "version", - "--short", - ]); - - const v = semver.coerce(dockerComposeVersion); - if (v !== null && !semver.gte(v, MINIMUM_DOCKER_COMPOSE_VERSION)) { - throw new Error( - `Unsupported Docker Compose version. Minimum required version is ${MINIMUM_DOCKER_COMPOSE_VERSION}. Installed version is ${v}.`, - ); - } - progress.succeed(`Docker Compose ${chalk.cyan(dockerComposeVersion)}`); - } catch (e: unknown) { - if ( - e instanceof Error && - (e as Error & { exitCode?: number }).exitCode === 125 - ) { - throw new Error( - "Docker Compose is required but not installed or the command execution failed. Please refer to the Docker Compose documentation for installation instructions: https://docs.docker.com/compose/install/", - ); - } - throw e; - } - - return true; -}; - -const checkBuildx = async (progress: Ora): Promise => { - try { - progress.start("Checking Docker Buildx version..."); - const { stdout: buildxOutput } = await execa("docker", [ - "buildx", - "version", - ]); - - const v = semver.coerce(buildxOutput); - if (v !== null && !semver.gte(v, MINIMUM_BUILDX_VERSION)) { - throw new Error( - `Unsupported Docker Buildx version. Minimum required version is ${MINIMUM_BUILDX_VERSION}. Installed version is ${v}.`, - ); - } - progress.succeed(`Docker Buildx ${chalk.cyan(v)}`); - - progress.start("Checking Docker RISC-V support..."); - const { stdout: platformsOutput } = await execa("docker", [ - "buildx", - "ls", - "--format", - "{{.Platforms}}", - ]); - - const buildxPlatforms: string[] = platformsOutput - .split(",") - .map((platform) => platform.trim()); - - if (!buildxPlatforms.includes("linux/riscv64")) { - throw new Error( - "Your system does not support riscv64 architecture. Run `docker run --privileged --rm tonistiigi/binfmt:riscv` to enable riscv64 support.", - ); - } - progress.succeed( - `Docker RISC-V support ${chalk.cyan("linux/riscv64")}`, - ); - } catch (e: unknown) { - if ( - e instanceof Error && - (e as Error & { exitCode?: number }).exitCode === 125 - ) { - throw new Error( - "Docker Buildx is required but not installed. Please refer to the Docker Desktop documentation for installation instructions: https://docs.docker.com/desktop/", - ); - } - throw e; - } - - return true; -}; +import ora from "ora"; +import { doctor } from "../api/doctor.js"; export const createDoctorCommand = () => { return new Command("doctor").action(async () => { - const progress = ora(); - try { - await checkDocker(progress); - await checkCompose(progress); - await checkBuildx(progress); - progress.succeed("Your system is ready."); - } catch (e: unknown) { - progress.fail((e as Error).message); + const progress = ora("Checking system requirements...").start(); + const { checks, ok } = await doctor(); + progress.stop(); + + for (const check of checks) { + if (check.ok) { + progress.succeed( + check.detail + ? `${check.name} ${chalk.cyan(check.detail)}` + : check.name, + ); + } else { + progress.fail(check.message ?? `${check.name} not found`); + } + } + + if (!ok) { process.exit(1); } + progress.succeed("Your system is ready."); }); }; diff --git a/apps/cli/src/commands/hash.ts b/apps/cli/src/commands/hash.ts index 54ccb239..2752dc68 100755 --- a/apps/cli/src/commands/hash.ts +++ b/apps/cli/src/commands/hash.ts @@ -1,6 +1,6 @@ import { Command } from "@commander-js/extra-typings"; import chalk from "chalk"; -import { getMachineHash } from "../base.js"; +import { hash } from "../api/hash.js"; export const createHashCommand = () => { return new Command("hash") @@ -9,14 +9,14 @@ export const createHashCommand = () => { ) .option("--json", "Format output as json.") .action(async ({ json }, command) => { - const hash = await getMachineHash(); - if (hash) { + const machineHash = await hash(); + if (machineHash) { if (!json) { console.log( - `${chalk.green("?")} Cartesi machine templateHash ${chalk.cyan(hash)}\n`, + `${chalk.green("?")} Cartesi machine templateHash ${chalk.cyan(machineHash)}\n`, ); } else { - process.stdout.write(JSON.stringify({ hash })); + process.stdout.write(JSON.stringify({ hash: machineHash })); } } else { command.error( diff --git a/apps/cli/src/commands/logs.ts b/apps/cli/src/commands/logs.ts index b103da1c..e23f5a37 100644 --- a/apps/cli/src/commands/logs.ts +++ b/apps/cli/src/commands/logs.ts @@ -1,6 +1,5 @@ import { Command } from "@commander-js/extra-typings"; -import { execa } from "execa"; -import { getProjectName, getServiceInfo } from "../base.js"; +import { logs } from "../api/logs.js"; export const createLogsCommand = () => { return new Command("logs") @@ -25,26 +24,15 @@ export const createLogsCommand = () => { ) .configureHelp({ showGlobalOptions: true }) .action(async (options) => { - const { follow, since, tail, until } = options; - const projectName = getProjectName(options); - const logOptions: string[] = []; - if (follow) logOptions.push("--follow"); - if (since) logOptions.push("--since", since); - if (tail) logOptions.push("--tail", tail); - if (until) logOptions.push("--until", until); + const { follow, projectName, since, tail, until } = options; - const serviceInfo = await getServiceInfo({ + await logs({ + follow, projectName, - service: "rollups_node", + since, + stream: true, + tail, + until, }); - if (!serviceInfo) { - throw new Error(`service rollups_node not found`); - } - - await execa( - "docker", - ["container", "logs", ...logOptions, serviceInfo.ID], - { stdio: "inherit" }, - ); }); }; diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 3ff5d45f..e9e855e2 100755 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -1,70 +1,25 @@ -import { - Command, - type CommandUnknownOpts, - Option, -} from "@commander-js/extra-typings"; +import { Command, Option } from "@commander-js/extra-typings"; import { ExitPromptError } from "@inquirer/core"; import chalk from "chalk"; import { ExecaError } from "execa"; -import getPort, { portNumbers } from "get-port"; import ora from "ora"; -import { - type Address, - createPublicClient, - type Hex, - http, - numberToHex, -} from "viem"; -import { - getApplicationConfig, - getMachineHash, - getProjectName, -} from "../base.js"; +import { build } from "../api/build.js"; +import { logs } from "../api/logs.js"; +import { run, type RunResult } from "../api/run.js"; import { nodeAllowedEnvironmentVariables } from "../compose/node.js"; -import { - DEFAULT_SDK_VERSION, - PREFERRED_PORT, - type WithdrawalConfig, -} from "../config.js"; -import { - AVAILABLE_SERVICES, - deployApplication, - host, - removeApplication, - type RollupsDeployment, - startEnvironment, - stopEnvironment, - waitHealthyEnvironment, -} from "../exec/rollups.js"; +import { DEFAULT_SDK_VERSION } from "../config.js"; +import { AVAILABLE_SERVICES } from "../exec/rollups.js"; import { keySelect } from "../prompts.js"; -import type { ForkConfig } from "../types/chain.js"; -import { assertForkConfig } from "../validations.js"; const commaSeparatedList = (value: string) => value.split(","); const shell = async (options: { - build?: CommandUnknownOpts; - deployment?: RollupsDeployment; - epochLength: number; - log?: CommandUnknownOpts; - projectName: string; - prt?: boolean; - salt: number; - withdrawalConfig?: WithdrawalConfig; - claimStagingPeriod: number; + config: string[]; + node: RunResult; + verbose: boolean; }) => { - const { - build, - epochLength, - log, - projectName, - prt, - withdrawalConfig, - claimStagingPeriod, - } = options; - - let lastDeployment = options.deployment; - let salt = options.salt; + const { config, node, verbose } = options; + const { projectName } = node; while (true) { try { @@ -81,12 +36,11 @@ const shell = async (options: { switch (option) { case "l": { try { - await log?.parseAsync( - ["--project-name", projectName, "--follow"], - { - from: "user", - }, - ); + await logs({ + follow: true, + projectName, + stream: true, + }); } catch (error: unknown) { if (error instanceof ExecaError) { // just continue gracefully @@ -100,25 +54,13 @@ const shell = async (options: { } case "b": { // build - await build?.parseAsync([], { from: "user" }); + await build({ + config, + progress: verbose ? "verbose" : "default", + }); // redeploy - const hash = await getMachineHash(); - if (hash) { - if (lastDeployment) { - await undeploy({ projectName }); - } - lastDeployment = await deploy({ - consensus: lastDeployment?.consensus, - epochLength, - hash, - projectName, - prt, - salt: numberToHex(salt++, { size: 32 }), - withdrawalConfig, - claimStagingPeriod, - }); - } + await node.deploy(); break; } @@ -136,89 +78,6 @@ const shell = async (options: { } }; -const undeploy = async (options: { projectName: string }) => { - const { projectName } = options; - const progress = ora(`${chalk.cyan(projectName)} undeploying...`).start(); - await removeApplication({ - application: projectName, - force: true, - projectName, - }); - progress.succeed(`${chalk.cyan(projectName)} undeployed`); -}; - -const deploy = async (options: { - consensus?: Address; - epochLength: number; - hash: Hex; - projectName: string; - prt?: boolean; - salt: Hex; - withdrawalConfig?: WithdrawalConfig; - claimStagingPeriod: number; -}) => { - const { - consensus, - epochLength, - hash, - projectName, - prt, - salt, - withdrawalConfig, - claimStagingPeriod, - } = options; - - // deploy application to node (onchain and offchain) - const progress = ora( - `deploying ${chalk.cyan(hash)} as ${chalk.cyan(projectName)}`, - ); - - const application = await deployApplication({ - consensus, - epochLength, - name: projectName, - projectName, - prt, - salt, - snapshotPath: "/var/lib/cartesi-rollups-node/snapshots/image", - withdrawalConfig, - claimStagingPeriod, - }); - progress.succeed( - `${chalk.cyan(projectName)} machine hash is ${chalk.cyan(hash)}`, - ); - progress.succeed( - `${chalk.cyan(projectName)} contract deployed at ${chalk.cyan(application.address)}`, - ); - return application; -}; - -const configureFork = async (options: { - forkUrl?: string; - forkBlockNumber?: number; -}): Promise => { - if (!options.forkUrl) { - return undefined; - } - - const url = options.forkUrl; - - // create a client to upstream so we can query it - const client = createPublicClient({ - transport: http(url), - }); - - // use explicit fork-block-number or query from upstream - const blockNumber = options.forkBlockNumber - ? BigInt(options.forkBlockNumber) - : await client.getBlockNumber(); - - // need to query fork chainId if forkUrl is specified - const chainId = await client.getChainId(); - - return { blockNumber, chainId, url }; -}; - export const createRunCommand = () => { return new Command("run") .description("Run a local cartesi node for the application.") @@ -311,25 +170,27 @@ export const createRunCommand = () => { [], ) .option("-v, --verbose", "verbose output", false) - .action(async (options, program) => { + .action(async (options) => { const { prt, blockTime, + config, cpus, defaultBlock, dryRun, epochLength, + forkBlockNumber, + forkUrl, memory, + port, + projectName, runtimeVersion, services, verbose, listSupportedVariables, claimStagingPeriod, - config: configFiles, } = options; - const progress = ora(); - if (listSupportedVariables) { const allowedVarsByService = { rollupsNode: nodeAllowedEnvironmentVariables, @@ -350,39 +211,23 @@ export const createRunCommand = () => { ); } - // project name explicitly defined or the current directory name - const projectName = getProjectName(options); - - // get application configuration (e.g. use withdrawal config if present) - const applicationConfig = getApplicationConfig(configFiles); - - // resolve port number, using the first free port in a range, unless explicitly set - const port = - options.port || - (await getPort({ - port: portNumbers(PREFERRED_PORT, PREFERRED_PORT + 10), - })); - - // configure optional anvil fork - const forkConfig = await configureFork(options); - - if (forkConfig) { - await assertForkConfig(forkConfig, { includePRT: prt }); - } - // if TTY is not attached, run on foreground (not detached) - const detach = process.stdin.isTTY; + const detach = !!process.stdin.isTTY; - // run compose environment (detached) - const { cmd, config } = await startEnvironment({ + const node = await run({ blockTime, + claimStagingPeriod, + config, cpus, defaultBlock, detach, dryRun, - forkConfig, + epochLength, + forkBlockNumber, + forkUrl, memory, port, + progress: verbose ? "verbose" : "default", projectName, prt, runtimeVersion, @@ -390,42 +235,15 @@ export const createRunCommand = () => { verbose, }); - // host address - const address = `${host}:${port}`; - - if (dryRun && config) { + if (dryRun) { // just show the docker compose configuration and quit - process.stdout.write(config); + if (node.config) { + process.stdout.write(node.config); + } return; } - progress.succeed( - `${chalk.cyan(projectName)} starting at ${chalk.cyan(`${address}`)}`, - ); - - // wait for the environment to be healthy - await waitHealthyEnvironment({ - name: projectName, - port, - projectName, - services, - }); - - // deploy the application - let deployment: RollupsDeployment | undefined; - let salt = 0; - const hash = await getMachineHash(); - if (hash) { - deployment = await deploy({ - epochLength, - hash, - projectName, - prt, - salt: numberToHex(salt++, { size: 32 }), - claimStagingPeriod, - withdrawalConfig: applicationConfig?.withdrawalConfig, - }); - } else { + if (!node.deployment) { console.warn( chalk.yellow( "machine snapshot not found, waiting for build", @@ -434,10 +252,12 @@ export const createRunCommand = () => { } const shutdown = async () => { - progress.start(`${chalk.cyan(projectName)} stopping...`); + const progress = ora().start( + `${chalk.cyan(node.projectName)} stopping...`, + ); try { - await stopEnvironment({ projectName }); - progress.succeed(`${chalk.cyan(projectName)} stopped`); + await node.stop(); + progress.succeed(`${chalk.cyan(node.projectName)} stopped`); } catch (e: unknown) { progress.fail( e instanceof Error ? e.message : "Unknown error", @@ -451,29 +271,13 @@ export const createRunCommand = () => { process.on("SIGINT", () => {}); process.on("SIGTERM", () => {}); - const log = program.parent?.commands.find( - (c) => c.name() === "logs", - ); - const build = program.parent?.commands.find( - (c) => c.name() === "build", - ); - await shell({ - build, - deployment, - epochLength, - log, - projectName, - prt, - salt, - claimStagingPeriod, - withdrawalConfig: applicationConfig?.withdrawalConfig, - }); + await shell({ config, node, verbose }); await shutdown(); } else { process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); try { - await cmd; + await node.cmd; } catch (error: unknown) { if (error instanceof ExecaError) { // just continue gracefully diff --git a/apps/cli/src/commands/send.ts b/apps/cli/src/commands/send.ts index 62e21ff9..068ee26c 100755 --- a/apps/cli/src/commands/send.ts +++ b/apps/cli/src/commands/send.ts @@ -1,115 +1,11 @@ import { Command, Option } from "@commander-js/extra-typings"; import ora from "ora"; -import { - encodeAbiParameters, - encodePacked, - getAddress, - isAddress, - isHex, - parseAbiParameters, - stringToHex, -} from "viem"; +import { getAddress, isAddress } from "viem"; +import { encodeInput, send } from "../api/send.js"; import { getProjectName } from "../base.js"; -import { inputBoxAbi, inputBoxAddress } from "../contracts.js"; import { bytesInput, getInputApplicationAddress } from "../prompts.js"; import { connect } from "../wallet.js"; -const getInput = async ( - input: string | undefined, - options: { - encoding?: "abi" | "abi-packed" | "hex" | "string"; - abiParams?: string; - }, -): Promise<`0x${string}` | undefined> => { - const { encoding } = options; - if (input) { - if (encoding === "hex") { - // validate if is a hex value - if (!isHex(input)) { - throw new Error("input encoded as hex must start with 0x"); - } - return input; - } - if (encoding === "string") { - // encode UTF-8 string as hex - return stringToHex(input); - } - if (encoding === "abi" || encoding === "abi-packed") { - const abiParams = options.abiParams; - if (!abiParams) { - throw new Error("Undefined input-abi-params"); - } - const abiParameters = parseAbiParameters(abiParams); - // TODO: decode values - const values = input.split(",").map((v, index) => { - if (index >= abiParameters.length) { - throw new Error( - `Too many values, expected ${abiParameters.length} values based on --input-abi-params '${abiParams}', parsing value at index ${index} from input '${input}'`, - ); - } - const param = abiParameters[index]; - switch (param.type) { - case "string": - return v; - case "bool": - if (v === "true") return true; - if (v === "false") return false; - throw new Error(`Invalid boolean value: ${v}`); - case "uint": - case "uint8": - case "uint16": - case "uint32": - case "uint64": - case "uint128": - case "uint256": - case "int": - case "int8": - case "int16": - case "int32": - case "int64": - case "int128": - case "int256": - try { - return BigInt(v); - } catch { - throw new Error(`Invalid uint value: ${v}`); - } - case "bytes": - if (isHex(v)) { - return v as `0x${string}`; - } - throw new Error(`Invalid bytes value: ${v}`); - case "address": - if (isAddress(v)) { - return getAddress(v); - } - throw new Error(`Invalid address value: ${v}`); - default: - throw new Error(`Unsupported type ${param.type}`); - } - }); - if (values.length !== abiParameters.length) { - throw new Error( - `Not enough values, expected ${abiParameters.length} values based on --input-abi-params '${abiParams}', parsed ${values.length} values from input '${input}'`, - ); - } - if (encoding === "abi") { - return encodeAbiParameters(abiParameters, values); - } else if (encoding === "abi-packed") { - const types = abiParameters.map((p) => p.type); - return encodePacked(types, values); - } - } - if (isHex(input)) { - // encoding not specified, if starts with 0x, assume hex - return input; - } - // encode UTF-8 string as hex - return stringToHex(input); - } - return undefined; -}; - export const createSendCommand = () => { const command = new Command("send") .description("Send input to the application") @@ -135,7 +31,7 @@ export const createSendCommand = () => { const projectName = getProjectName(options); - // connect to anvil + // connect to anvil, so we can ask which account to impersonate const testClient = await connect(options); // the input sender, impersonated @@ -151,25 +47,22 @@ export const createSendCommand = () => { }); const payload = - (await getInput(input, options)) || + (await encodeInput(input, options)) || (await bytesInput({ abiParams: options.abiParams, encoding: options.encoding, message: "Input", })); - const { request } = await testClient.simulateContract({ - address: inputBoxAddress, - abi: inputBoxAbi, - account, - args: [applicationAddress, payload], - functionName: "addInput", - }); - - const hash = await testClient.writeContract(request); const progress = ora("Sending input...").start(); - await testClient.waitForTransactionReceipt({ hash }); - progress.succeed(`Input sent: ${hash}`); + const { transactionHash } = await send({ + application: applicationAddress, + client: testClient, + from: account, + payload, + projectName, + }); + progress.succeed(`Input sent: ${transactionHash}`); }); return command; }; diff --git a/apps/cli/src/commands/shell.ts b/apps/cli/src/commands/shell.ts index 9ffaa8c8..7131971b 100755 --- a/apps/cli/src/commands/shell.ts +++ b/apps/cli/src/commands/shell.ts @@ -1,9 +1,5 @@ import { Command } from "@commander-js/extra-typings"; -import { ExecaError } from "execa"; -import fs from "fs-extra"; -import path from "node:path"; -import { getApplicationConfig, getContextPath } from "../base.js"; -import { bootMachine } from "../machine.js"; +import { shell } from "../api/shell.js"; export const createShellCommand = () => { return new Command("shell") @@ -16,49 +12,8 @@ export const createShellCommand = () => { ) .option("--run-as-root", "run as root user", false) .action(async (options) => { - const { command, runAsRoot } = options; + const { command, config, runAsRoot } = options; - // get application configuration from 'cartesi.toml' - const config = getApplicationConfig(options.config); - - // destination directory for image and intermediate files - const destination = path.resolve(getContextPath()); - - // check if all drives are built - for (const [name, drive] of Object.entries(config.drives)) { - const filename = `${name}.${drive.format}`; - const pathname = getContextPath(filename); - if (!fs.existsSync(pathname)) { - throw new Error(`drive '${name}' not built, run 'build'`); - } - } - - // create shell entrypoint - config.machine.entrypoint = command; - - // run as root if flag is set - config.machine.user = runAsRoot ? "root" : undefined; - - // boot machine - try { - await bootMachine( - config, - undefined, - { interactive: true }, // start with interactive mode on - { - cwd: destination, - stdio: "inherit", - tty: true, - }, - ); - } catch (error: unknown) { - if (error instanceof ExecaError) { - // just continue gracefully - if (error.exitCode === 130) { - return; - } - throw error; - } - } + await shell({ command, config, runAsRoot }); }); }; diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 5ea37ee5..6700199a 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -1,8 +1,7 @@ import { Command } from "@commander-js/extra-typings"; import chalk from "chalk"; import Table from "cli-table3"; -import { getProjectName, getServiceState } from "../base.js"; -import { getDeployments } from "../exec/rollups.js"; +import { status } from "../api/status.js"; export const createStatusCommand = () => { return new Command("status") @@ -16,29 +15,22 @@ export const createStatusCommand = () => { .action(async (options) => { const { json } = options; - const projectName = getProjectName(options); - - const status = await getServiceState({ - projectName, - service: "rollups_node", - }); - const deployments = await getDeployments({ - projectName, - }); + const { deployments, projectName, running, state } = + await status(options); if (json) { process.stdout.write( JSON.stringify({ - status, + status: state, deployments, }), ); } else { console.log( - `${chalk.cyan(projectName)} is ${status === "running" ? chalk.green("running") : chalk.red("not running")}`, + `${chalk.cyan(projectName)} is ${running ? chalk.green("running") : chalk.red("not running")}`, ); - if (status === "running") { + if (running) { if (deployments.length === 0) { console.log(chalk.red("no applications deployed")); } else { diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts index 7096f8ff..af2b65f6 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config.ts @@ -95,7 +95,7 @@ export const DEFAULT_SDK_IMAGE = "cartesi/sdk"; export const PREFERRED_PORT = 6751; type Builder = "directory" | "docker" | "empty" | "none" | "tar"; -type DriveFormat = "ext2" | "sqfs"; +export type DriveFormat = "ext2" | "sqfs"; export type ImageInfo = { cmd: string[]; diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index 64392327..ea2c19ce 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -378,9 +378,10 @@ export const waitHealthyEnvironment = async (options: { name?: string; port: number; projectName: string; + renderer?: "default" | "silent" | "verbose"; services: string[]; }) => { - const { name, port, projectName, services } = options; + const { name, port, projectName, renderer = "default", services } = options; // select subset of optional services const optionalServices = @@ -405,7 +406,7 @@ export const waitHealthyEnvironment = async (options: { }); }); - const tasks = new Listr(monitorTasks, { concurrent: true }); + const tasks = new Listr(monitorTasks, { concurrent: true, renderer }); await tasks.run(); }; diff --git a/apps/cli/src/lib.ts b/apps/cli/src/lib.ts new file mode 100644 index 00000000..24ce8efd --- /dev/null +++ b/apps/cli/src/lib.ts @@ -0,0 +1,53 @@ +/** + * Programmatic API of the Cartesi CLI. + * + * Every command of the CLI is also available as a function, so applications can + * be built, run and inspected from a script: + * + * ```ts + * import { build, hash, run } from "@cartesi/cli"; + * + * await build(); + * console.log(await hash()); + * + * const node = await run(); + * // ... + * await node.stop(); + * ``` + * + * Unless stated otherwise, functions operate on the current working directory, + * just like the CLI, and are silent: nothing is written to the terminal. Pass + * `progress: "default"` to get the same output as the CLI. + */ +export * from "./api/index.js"; + +// configuration of an application ('cartesi.toml') +export { + type Config, + DEFAULT_SDK_IMAGE, + DEFAULT_SDK_VERSION, + defaultConfig, + defaultMachineConfig, + defaultRootDriveConfig, + type DirectoryDriveConfig, + type DockerDriveConfig, + type DriveConfig, + type DriveFormat, + type EmptyDriveConfig, + type ExistingDriveConfig, + type ImageInfo, + type MachineConfig, + parse as parseConfig, + PREFERRED_PORT, + type TarDriveConfig, + type WithdrawalConfig, +} from "./config.js"; + +// runtime environment +export { + AVAILABLE_SERVICES, + type RollupsDeployment, +} from "./exec/rollups.js"; + +export type { ForkConfig } from "./types/chain.js"; +export { cartesi, type DevnetClient } from "./wallet.js"; diff --git a/apps/cli/src/validations.ts b/apps/cli/src/validations.ts index 6495a28c..88e309f8 100644 --- a/apps/cli/src/validations.ts +++ b/apps/cli/src/validations.ts @@ -16,10 +16,10 @@ import { erc721PortalConfig, etherPortalConfig, inputBoxConfig, -} from "./contracts"; -import ForkChainValidationError from "./errors/ForkChainValidationError"; -import UnsupportedForkChainError from "./errors/UnsupportedForkChainError"; -import type { ForkConfig } from "./types/chain"; +} from "./contracts.js"; +import ForkChainValidationError from "./errors/ForkChainValidationError.js"; +import UnsupportedForkChainError from "./errors/UnsupportedForkChainError.js"; +import type { ForkConfig } from "./types/chain.js"; interface AssertForkConfigOptions { includePRT?: boolean; diff --git a/apps/cli/src/wallet.ts b/apps/cli/src/wallet.ts index 0de7c6da..00bceb50 100644 --- a/apps/cli/src/wallet.ts +++ b/apps/cli/src/wallet.ts @@ -20,6 +20,7 @@ export const cartesi = defineChain({ const getRpcUrl = async (options: { rpcUrl?: string; projectName?: string; + interactive?: boolean; }) => { // if rpcUrl is provided, use it if (options.rpcUrl) return options.rpcUrl; @@ -29,7 +30,14 @@ const getRpcUrl = async (options: { const projectName = getProjectName(options); const host = await getProjectPort({ projectName }); return `http://${host}/anvil`; - } catch { + } catch (error: unknown) { + if (options.interactive === false) { + // no terminal to ask the user for the RPC URL + throw new Error( + `Unable to resolve the RPC URL of project '${getProjectName(options)}', make sure it is running, or define 'rpcUrl'`, + { cause: error }, + ); + } return await input({ message: "RPC URL", default: `http://127.0.0.1:${PREFERRED_PORT}/anvil`, @@ -40,6 +48,12 @@ const getRpcUrl = async (options: { export const connect = async (options: { rpcUrl?: string; projectName?: string; + + /** + * Ask for the RPC URL when it can't be resolved from the running project. + * @default true + */ + interactive?: boolean; }) => { // resolve rpc url const rpcUrl = await getRpcUrl(options); @@ -55,3 +69,6 @@ export const connect = async (options: { .extend(walletActions); return client; }; + +/** Client connected to the devnet of a local environment. */ +export type DevnetClient = Awaited>; diff --git a/apps/cli/tests/unit/api/deposit.test.ts b/apps/cli/tests/unit/api/deposit.test.ts new file mode 100644 index 00000000..8926c0ad --- /dev/null +++ b/apps/cli/tests/unit/api/deposit.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "bun:test"; +import { parseEther } from "viem"; +import { + DepositError, + InsufficientBalanceError, + InvalidAmountError, + parseAmount, + TokenNotFoundError, +} from "../../../src/api/deposit/index.js"; + +describe("api/deposit", () => { + describe("parseAmount", () => { + it("should take a bigint as base units", () => { + expect(parseAmount(42n, 18)).toBe(42n); + expect(parseAmount(0n, 6)).toBe(0n); + }); + + it("should take a string as display units", () => { + expect(parseAmount("1.5", 18)).toBe(parseEther("1.5")); + expect(parseAmount("2", 6)).toBe(2_000_000n); + }); + + it("should take a string as an integer when there are no decimals", () => { + expect(parseAmount("42", 0)).toBe(42n); + }); + }); + + describe("errors", () => { + it("should all be deposit errors", () => { + expect(new InsufficientBalanceError()).toBeInstanceOf(DepositError); + expect(new InvalidAmountError("too small")).toBeInstanceOf( + DepositError, + ); + expect(new TokenNotFoundError("no token")).toBeInstanceOf( + DepositError, + ); + }); + + it("should have a default message for an insufficient balance", () => { + expect(new InsufficientBalanceError().message).toBe( + "Insufficient balance", + ); + expect( + new InsufficientBalanceError( + "Insufficient balance for token ID 1", + ).message, + ).toBe("Insufficient balance for token ID 1"); + }); + }); +}); diff --git a/apps/cli/tests/unit/api/send.test.ts b/apps/cli/tests/unit/api/send.test.ts new file mode 100644 index 00000000..f6690b1e --- /dev/null +++ b/apps/cli/tests/unit/api/send.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "bun:test"; +import { encodeAbiParameters, parseAbiParameters, stringToHex } from "viem"; +import { encodeInput } from "../../../src/api/send.js"; + +describe("api/send", () => { + describe("encodeInput", () => { + it("should return undefined for an empty input", async () => { + expect(await encodeInput(undefined, {})).toBeUndefined(); + expect(await encodeInput("", {})).toBeUndefined(); + }); + + it("should assume hex when the input starts with 0x", async () => { + expect(await encodeInput("0xdeadbeef", {})).toBe("0xdeadbeef"); + }); + + it("should encode as an UTF-8 string by default", async () => { + expect(await encodeInput("hello", {})).toBe(stringToHex("hello")); + }); + + it("should encode as an UTF-8 string when requested", async () => { + expect( + await encodeInput("0xdeadbeef", { encoding: "string" }), + ).toBe(stringToHex("0xdeadbeef")); + }); + + it("should validate a hex encoded input", async () => { + expect( + encodeInput("deadbeef", { encoding: "hex" }), + ).rejects.toThrow("input encoded as hex must start with 0x"); + }); + + it("should encode abi parameters", async () => { + const abiParams = "address,uint256,bool,string"; + const encoded = await encodeInput( + "0x1111111111111111111111111111111111111111,42,true,hello", + { abiParams, encoding: "abi" }, + ); + expect(encoded).toBe( + encodeAbiParameters(parseAbiParameters(abiParams), [ + "0x1111111111111111111111111111111111111111", + 42n, + true, + "hello", + ]), + ); + }); + + it("should encode packed abi parameters", async () => { + const encoded = await encodeInput("42,hello", { + abiParams: "uint256,string", + encoding: "abi-packed", + }); + expect(encoded).toBe( + `0x${42n.toString(16).padStart(64, "0")}${stringToHex("hello").slice(2)}`, + ); + }); + + it("should require abi params for abi encodings", async () => { + expect(encodeInput("42", { encoding: "abi" })).rejects.toThrow( + "Undefined input-abi-params", + ); + }); + + it("should validate the number of abi values", async () => { + expect( + encodeInput("42", { + abiParams: "uint256,string", + encoding: "abi", + }), + ).rejects.toThrow("Not enough values"); + + expect( + encodeInput("42,hello", { + abiParams: "uint256", + encoding: "abi", + }), + ).rejects.toThrow("Too many values"); + }); + + it("should validate abi values", async () => { + expect( + encodeInput("nan", { abiParams: "uint256", encoding: "abi" }), + ).rejects.toThrow("Invalid uint value: nan"); + + expect( + encodeInput("yes", { abiParams: "bool", encoding: "abi" }), + ).rejects.toThrow("Invalid boolean value: yes"); + + expect( + encodeInput("0x00", { abiParams: "address", encoding: "abi" }), + ).rejects.toThrow("Invalid address value: 0x00"); + + expect( + encodeInput("nothex", { abiParams: "bytes", encoding: "abi" }), + ).rejects.toThrow("Invalid bytes value: nothex"); + }); + }); +}); diff --git a/apps/cli/tests/unit/api/types.test.ts b/apps/cli/tests/unit/api/types.test.ts new file mode 100644 index 00000000..d528cb20 --- /dev/null +++ b/apps/cli/tests/unit/api/types.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { listrRenderer, resolveConfig } from "../../../src/api/types.js"; +import { defaultConfig } from "../../../src/config.js"; + +const fixture = (...paths: string[]) => + path.join(__dirname, "..", "config", "fixtures", ...paths); + +describe("api/types", () => { + describe("resolveConfig", () => { + it("should default to the default configuration", () => { + // there is no cartesi.toml at the root of the repository + expect(resolveConfig()).toEqual(defaultConfig()); + }); + + it("should return a configuration object as is", () => { + const config = defaultConfig(); + config.sdk = "my/sdk:1.0.0"; + expect(resolveConfig(config)).toBe(config); + }); + + it("should read a configuration file", () => { + const config = resolveConfig(fixture("drives", "rives.toml")); + expect(Object.keys(config.drives)).toEqual([ + "root", + "doom", + "tetrix", + ]); + expect(config.withdrawalConfig).toBeUndefined(); + }); + + it("should merge a list of configuration files", () => { + const config = resolveConfig([ + fixture("drives", "rives.toml"), + fixture("withdrawal", "config.toml"), + ]); + expect(Object.keys(config.drives)).toEqual([ + "root", + "doom", + "tetrix", + ]); + expect(config.withdrawalConfig?.guardian).toBe( + "0x1111111111111111111111111111111111111111", + ); + }); + + it("should fail for a configuration file that does not exist", () => { + expect(() => resolveConfig("undefined.toml")).toThrow( + "Config file undefined.toml does not exist", + ); + }); + }); + + describe("listrRenderer", () => { + it("should be silent by default", () => { + expect(listrRenderer()).toBe("silent"); + expect(listrRenderer("silent")).toBe("silent"); + expect(listrRenderer("default")).toBe("default"); + expect(listrRenderer("verbose")).toBe("verbose"); + }); + }); +}); diff --git a/apps/cli/tsconfig.build.json b/apps/cli/tsconfig.build.json new file mode 100644 index 00000000..819a6612 --- /dev/null +++ b/apps/cli/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "allowImportingTsExtensions": false, + "outDir": "dist/types", + "rootDir": "src" + }, + "include": ["src"] +}