From 8be539b07e0f97ddba3637ea4fabd2e16ca3f7fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 04:48:21 +0000 Subject: [PATCH 1/3] feat(cli): expose commands as a programmatic library API Every command is now also a function, so applications can be built, run and inspected from a script: import { build, hash, run } from "@cartesi/cli"; The command actions were moved to `src/api`, and the Commander commands became thin wrappers around them. The API functions take plain option objects with defaults, return values instead of printing, throw instead of exiting, and are silent unless `progress` is set to "default" or "verbose", which reproduces the CLI output. `src/lib.ts` is the new package entrypoint, bundled to `dist/lib.js` and typed by `dist/types`, generated with `tsc --emitDeclarationOnly`. The `bin` entrypoint is unchanged. While rewiring the run command, "build and redeploy" now builds with the configuration files given to `run`, instead of always `cartesi.toml`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015SA64hXXCsNWisTYLVpEBn --- .changeset/wild-crabs-invent.md | 5 + apps/cli/README.md | 48 +++ apps/cli/build.ts | 10 +- apps/cli/package.json | 14 +- apps/cli/src/api/address-book.ts | 24 ++ apps/cli/src/api/build.ts | 195 ++++++++++++ apps/cli/src/api/clean.ts | 10 + apps/cli/src/api/create.ts | 49 +++ apps/cli/src/api/doctor.ts | 202 +++++++++++++ apps/cli/src/api/hash.ts | 9 + apps/cli/src/api/index.ts | 12 + apps/cli/src/api/logs.ts | 98 ++++++ apps/cli/src/api/run.ts | 413 ++++++++++++++++++++++++++ apps/cli/src/api/send.ts | 230 ++++++++++++++ apps/cli/src/api/shell.ts | 80 +++++ apps/cli/src/api/status.ts | 48 +++ apps/cli/src/api/types.ts | 74 +++++ apps/cli/src/base.ts | 2 +- apps/cli/src/commands/address-book.ts | 5 +- apps/cli/src/commands/build.ts | 150 +--------- apps/cli/src/commands/clean.ts | 5 +- apps/cli/src/commands/create.ts | 19 +- apps/cli/src/commands/doctor.ts | 144 ++------- apps/cli/src/commands/hash.ts | 10 +- apps/cli/src/commands/logs.ts | 28 +- apps/cli/src/commands/run.ts | 290 +++--------------- apps/cli/src/commands/send.ts | 131 +------- apps/cli/src/commands/shell.ts | 51 +--- apps/cli/src/commands/status.ts | 20 +- apps/cli/src/config.ts | 2 +- apps/cli/src/exec/rollups.ts | 5 +- apps/cli/src/lib.ts | 53 ++++ apps/cli/src/validations.ts | 8 +- apps/cli/src/wallet.ts | 19 +- apps/cli/tests/unit/api/send.test.ts | 98 ++++++ apps/cli/tests/unit/api/types.test.ts | 62 ++++ apps/cli/tsconfig.build.json | 12 + 37 files changed, 1881 insertions(+), 754 deletions(-) create mode 100644 .changeset/wild-crabs-invent.md create mode 100644 apps/cli/src/api/address-book.ts create mode 100644 apps/cli/src/api/build.ts create mode 100644 apps/cli/src/api/clean.ts create mode 100644 apps/cli/src/api/create.ts create mode 100644 apps/cli/src/api/doctor.ts create mode 100644 apps/cli/src/api/hash.ts create mode 100644 apps/cli/src/api/index.ts create mode 100644 apps/cli/src/api/logs.ts create mode 100644 apps/cli/src/api/run.ts create mode 100644 apps/cli/src/api/send.ts create mode 100644 apps/cli/src/api/shell.ts create mode 100644 apps/cli/src/api/status.ts create mode 100644 apps/cli/src/api/types.ts create mode 100644 apps/cli/src/lib.ts create mode 100644 apps/cli/tests/unit/api/send.test.ts create mode 100644 apps/cli/tests/unit/api/types.test.ts create mode 100644 apps/cli/tsconfig.build.json 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..82d18228 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -17,3 +17,51 @@ 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, 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" }); + +await node.stop(); +``` + +The following functions are available: `addressBook`, `build`, `clean`, +`create`, `doctor`, `hash`, `logs`, `run`, `send`, `shell` and `status`. The +`deposit` command is not exposed yet, as it is interactive. + +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. + +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/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/doctor.ts b/apps/cli/src/api/doctor.ts new file mode 100644 index 00000000..7474f5ee --- /dev/null +++ b/apps/cli/src/api/doctor.ts @@ -0,0 +1,202 @@ +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; + + /** Requirements checked, in the order they were checked. */ + checks: DoctorCheck[]; +}; + +export type DoctorOptions = { + /** Called with a description of the requirement when its check starts. */ + onCheckStart?: (title: string) => void; + + /** Called when the check of a requirement completes. */ + onCheck?: (check: DoctorCheck) => void; +}; + +type Check = { + name: string; + title: 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", + title: "Checking Docker Engine version...", + run: checkDocker, + }, + { + name: "Docker Compose", + title: "Checking Docker Compose version...", + run: checkCompose, + }, + { + name: "Docker Buildx", + title: "Checking Docker Buildx version...", + run: checkBuildx, + }, + { + name: "Docker RISC-V support", + title: "Checking Docker RISC-V support...", + run: checkRiscv, + }, +]; + +/** + * Check whether the system has everything needed to build and run Cartesi + * applications. Checks are executed in order and stop at the first failure. + * @param options doctor options + * @returns the result of every check executed + */ +export const doctor = async ( + options: DoctorOptions = {}, +): Promise => { + const { onCheck, onCheckStart } = options; + const results: DoctorCheck[] = []; + + for (const { name, run, title } of checks) { + onCheckStart?.(title); + let check: DoctorCheck; + try { + check = { name, ok: true, detail: await run() }; + } catch (e: unknown) { + check = { + name, + ok: false, + message: e instanceof Error ? e.message : String(e), + }; + } + results.push(check); + onCheck?.(check); + + if (!check.ok) { + // stop at the first failed check + return { ok: false, checks: results }; + } + } + + return { ok: true, 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..9a154eea --- /dev/null +++ b/apps/cli/src/api/index.ts @@ -0,0 +1,12 @@ +export * from "./address-book.js"; +export * from "./build.js"; +export * from "./clean.js"; +export * from "./create.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..962a8ae8 --- /dev/null +++ b/apps/cli/src/api/send.ts @@ -0,0 +1,230 @@ +import { + type Address, + encodeAbiParameters, + encodePacked, + getAddress, + type Hash, + type Hex, + isAddress, + isHex, + parseAbiParameters, + stringToHex, +} from "viem"; +import { getProjectName } from "../base.js"; +import { inputBoxAbi, inputBoxAddress } from "../contracts.js"; +import { getApplicationAddress } from "../exec/rollups.js"; +import { connect, type DevnetClient } from "../wallet.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 = EncodeInputOptions & { + /** Input payload, encoded according to `encoding`. */ + input?: string; + + /** Input payload already encoded as hex. Takes precedence over `input`. */ + payload?: Hex; + + /** + * Address of the application the input is sent to. + * @default the application deployed to the local node + */ + application?: Address; + + /** + * Address of the input 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 input with. + * @default a client connected to the devnet of the running project + */ + client?: DevnetClient; +}; + +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 => { + const projectName = getProjectName(options); + + // 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"); + } + + // 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 input sender, impersonated + const from = options.from ?? (await client.getAddresses())[0]; + + 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/doctor.ts b/apps/cli/src/commands/doctor.ts index bb3bb951..f864c889 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 { ok } = await doctor({ + onCheckStart: (title) => progress.start(title), + onCheck: (check) => { + 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..864c0fa7 --- /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 } 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/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"] +} From ee045ea846ed020e39d084352c3705014c990d18 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 16:22:31 +0000 Subject: [PATCH 2/3] refactor(cli): run doctor checks concurrently, drop the callbacks The doctor API now runs every check in parallel and returns all results, failed and successful, instead of stopping at the first failure and reporting progress through callbacks. The command renders the results after they are gathered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015SA64hXXCsNWisTYLVpEBn --- apps/cli/src/api/doctor.ts | 85 ++++++++++----------------------- apps/cli/src/commands/doctor.ts | 30 ++++++------ 2 files changed, 39 insertions(+), 76 deletions(-) diff --git a/apps/cli/src/api/doctor.ts b/apps/cli/src/api/doctor.ts index 7474f5ee..3e4ccf16 100644 --- a/apps/cli/src/api/doctor.ts +++ b/apps/cli/src/api/doctor.ts @@ -23,21 +23,12 @@ export type DoctorResult = { /** Whether all requirements are satisfied. */ ok: boolean; - /** Requirements checked, in the order they were checked. */ + /** Every requirement checked, satisfied or not. */ checks: DoctorCheck[]; }; -export type DoctorOptions = { - /** Called with a description of the requirement when its check starts. */ - onCheckStart?: (title: string) => void; - - /** Called when the check of a requirement completes. */ - onCheck?: (check: DoctorCheck) => void; -}; - type Check = { name: string; - title: string; run: () => Promise; }; @@ -143,60 +134,32 @@ const checkRiscv = async (): Promise => { }; const checks: Check[] = [ - { - name: "Docker Engine", - title: "Checking Docker Engine version...", - run: checkDocker, - }, - { - name: "Docker Compose", - title: "Checking Docker Compose version...", - run: checkCompose, - }, - { - name: "Docker Buildx", - title: "Checking Docker Buildx version...", - run: checkBuildx, - }, - { - name: "Docker RISC-V support", - title: "Checking Docker RISC-V support...", - run: checkRiscv, - }, + { 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. Checks are executed in order and stop at the first failure. - * @param options doctor options - * @returns the result of every check executed + * 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 ( - options: DoctorOptions = {}, -): Promise => { - const { onCheck, onCheckStart } = options; - const results: DoctorCheck[] = []; - - for (const { name, run, title } of checks) { - onCheckStart?.(title); - let check: DoctorCheck; - try { - check = { name, ok: true, detail: await run() }; - } catch (e: unknown) { - check = { - name, - ok: false, - message: e instanceof Error ? e.message : String(e), - }; - } - results.push(check); - onCheck?.(check); - - if (!check.ok) { - // stop at the first failed check - return { ok: false, checks: results }; - } - } - - return { ok: true, checks: results }; +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/commands/doctor.ts b/apps/cli/src/commands/doctor.ts index f864c889..435c0130 100755 --- a/apps/cli/src/commands/doctor.ts +++ b/apps/cli/src/commands/doctor.ts @@ -5,21 +5,21 @@ import { doctor } from "../api/doctor.js"; export const createDoctorCommand = () => { return new Command("doctor").action(async () => { - const progress = ora(); - const { ok } = await doctor({ - onCheckStart: (title) => progress.start(title), - onCheck: (check) => { - if (check.ok) { - progress.succeed( - check.detail - ? `${check.name} ${chalk.cyan(check.detail)}` - : check.name, - ); - } else { - progress.fail(check.message ?? `${check.name} not found`); - } - }, - }); + 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); From 33b100c2220e02756b7cf80dad149b836e45ff98 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 18:52:34 +0000 Subject: [PATCH 3/3] feat(cli): expose the deposit commands as library functions Adds `depositEther`, `depositErc20`, `depositErc721`, `depositErc1155` and `depositErc1155Batch` to the library API, moving the deposit logic out of the commands. The commands keep the prompts, and pass everything resolved to the API. Amounts are given in the base unit of the asset as a bigint, or in its display unit as a string. Deposits that cannot be made now throw `DepositError` subclasses instead of printing and returning, which the commands render the same way as before. The connection setup shared by `send` and the deposits (client, sender and application address) moved to `resolveConnection`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015SA64hXXCsNWisTYLVpEBn --- .changeset/tidy-moons-shake.md | 5 + apps/cli/README.md | 18 +- apps/cli/src/api/connection.ts | 86 +++++++ apps/cli/src/api/deposit/common.ts | 95 +++++++ apps/cli/src/api/deposit/erc1155.ts | 303 +++++++++++++++++++++++ apps/cli/src/api/deposit/erc20.ts | 157 ++++++++++++ apps/cli/src/api/deposit/erc721.ts | 189 ++++++++++++++ apps/cli/src/api/deposit/ether.ts | 69 ++++++ apps/cli/src/api/deposit/index.ts | 15 ++ apps/cli/src/api/index.ts | 2 + apps/cli/src/api/send.ts | 70 +----- apps/cli/src/commands/deposit/erc1155.ts | 287 ++++----------------- apps/cli/src/commands/deposit/erc20.ts | 157 +++--------- apps/cli/src/commands/deposit/erc721.ts | 175 ++----------- apps/cli/src/commands/deposit/error.ts | 15 ++ apps/cli/src/commands/deposit/ether.ts | 59 ++--- apps/cli/src/lib.ts | 2 +- apps/cli/tests/unit/api/deposit.test.ts | 50 ++++ 18 files changed, 1137 insertions(+), 617 deletions(-) create mode 100644 .changeset/tidy-moons-shake.md create mode 100644 apps/cli/src/api/connection.ts create mode 100644 apps/cli/src/api/deposit/common.ts create mode 100644 apps/cli/src/api/deposit/erc1155.ts create mode 100644 apps/cli/src/api/deposit/erc20.ts create mode 100644 apps/cli/src/api/deposit/erc721.ts create mode 100644 apps/cli/src/api/deposit/ether.ts create mode 100644 apps/cli/src/api/deposit/index.ts create mode 100644 apps/cli/src/commands/deposit/error.ts create mode 100644 apps/cli/tests/unit/api/deposit.test.ts 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/apps/cli/README.md b/apps/cli/README.md index 82d18228..3290c21e 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -28,7 +28,7 @@ npm install @cartesi/cli ``` ```ts -import { build, hash, run, send } from "@cartesi/cli"; +import { build, depositErc20, hash, run, send } from "@cartesi/cli"; // build the application, same as `cartesi build` await build(); @@ -43,12 +43,16 @@ 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`, `doctor`, `hash`, `logs`, `run`, `send`, `shell` and `status`. The -`deposit` command is not exposed yet, as it is interactive. +`create`, `depositErc20`, `depositErc721`, `depositErc1155`, +`depositErc1155Batch`, `depositEther`, `doctor`, `hash`, `logs`, `run`, `send`, +`shell` and `status`. A few things to keep in mind: @@ -61,7 +65,13 @@ A few things to keep in mind: - 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. + 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/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/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/index.ts b/apps/cli/src/api/index.ts index 9a154eea..bbad67f1 100644 --- a/apps/cli/src/api/index.ts +++ b/apps/cli/src/api/index.ts @@ -1,7 +1,9 @@ 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"; diff --git a/apps/cli/src/api/send.ts b/apps/cli/src/api/send.ts index 962a8ae8..af36712b 100644 --- a/apps/cli/src/api/send.ts +++ b/apps/cli/src/api/send.ts @@ -10,10 +10,8 @@ import { parseAbiParameters, stringToHex, } from "viem"; -import { getProjectName } from "../base.js"; import { inputBoxAbi, inputBoxAddress } from "../contracts.js"; -import { getApplicationAddress } from "../exec/rollups.js"; -import { connect, type DevnetClient } from "../wallet.js"; +import { type ConnectionOptions, resolveConnection } from "./connection.js"; /** Encoding of an application input. */ export type InputEncoding = "abi" | "abi-packed" | "hex" | "string"; @@ -127,43 +125,14 @@ export const encodeInput = async ( return undefined; }; -export type SendOptions = EncodeInputOptions & { - /** Input payload, encoded according to `encoding`. */ - input?: string; +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; - - /** - * Address of the application the input is sent to. - * @default the application deployed to the local node - */ - application?: Address; - - /** - * Address of the input 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 input with. - * @default a client connected to the devnet of the running project - */ - client?: DevnetClient; -}; + /** Input payload already encoded as hex. Takes precedence over `input`. */ + payload?: Hex; + }; export type SendResult = { /** Address of the application the input was sent to. */ @@ -185,8 +154,6 @@ export type SendResult = { * @returns the input sent, and the transaction that added it to the input box */ export const send = async (options: SendOptions): Promise => { - const projectName = getProjectName(options); - // encode the payload, unless it is already encoded const payload = options.payload ?? (await encodeInput(options.input, options)); @@ -194,26 +161,7 @@ export const send = async (options: SendOptions): Promise => { throw new Error("Undefined input payload"); } - // 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 input sender, impersonated - const from = options.from ?? (await client.getAddresses())[0]; + const { application, client, from } = await resolveConnection(options); const { request } = await client.simulateContract({ address: inputBoxAddress, 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/lib.ts b/apps/cli/src/lib.ts index 864c0fa7..24ce8efd 100644 --- a/apps/cli/src/lib.ts +++ b/apps/cli/src/lib.ts @@ -50,4 +50,4 @@ export { } from "./exec/rollups.js"; export type { ForkConfig } from "./types/chain.js"; -export { cartesi } from "./wallet.js"; +export { cartesi, type DevnetClient } from "./wallet.js"; 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"); + }); + }); +});