From 8c77235fe9834d46a917a3f3b803d4488106798a Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 1 Oct 2026 21:26:13 +0100 Subject: [PATCH 01/25] feat(cli): add cartesi client to query the local node --- apps/cli/package.json | 1 + apps/cli/src/node.ts | 53 +++++++++++++++++++++++++++++++++++++++++++ bun.lock | 7 ++++++ 3 files changed, 61 insertions(+) create mode 100644 apps/cli/src/node.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 8ef34934..ad811aa7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -15,6 +15,7 @@ "/dist" ], "dependencies": { + "@cartesi/client": "2.0.0-alpha.38", "@commander-js/extra-typings": "^15.0.0", "@inquirer/confirm": "^6.3.3", "@inquirer/core": "^12.0.4", diff --git a/apps/cli/src/node.ts b/apps/cli/src/node.ts new file mode 100644 index 00000000..54e1ab81 --- /dev/null +++ b/apps/cli/src/node.ts @@ -0,0 +1,53 @@ +import { + type Application, + type CartesiPublicClient, + createCartesiPublicClient, +} from "@cartesi/client"; +import pRetry from "p-retry"; +import { type Address, http } from "viem"; +import { getProjectPort } from "./exec/rollups.js"; + +/** + * Create a client for the JSON-RPC API of the local rollups node + * @param options projectName + * @returns client exposing the cartesi_* methods + */ +export const getNodeClient = async (options: { + projectName: string; +}): Promise => { + const host = await getProjectPort(options); + return createCartesiPublicClient({ + transport: http(`http://${host}/rpc`), + }); +}; + +/** + * Wait until the node reports an application state that satisfies the + * predicate. The node observes chain events with some lag, so a state change + * caused by a transaction is not visible right after it is mined. + * @returns the application in the expected state + */ +export const waitForApplication = async (options: { + application: Address; + client: CartesiPublicClient; + message: string; + predicate: (application: Application) => boolean; + retries?: number; +}): Promise => { + const { application, client, message, predicate, retries = 20 } = options; + return pRetry( + async () => { + const app = await client.getApplication({ application }); + if (!predicate(app)) { + throw new Error(message); + } + return app; + }, + { + retries, + minTimeout: 500, + maxTimeout: 2_000, + factor: 1.2, + }, + ); +}; diff --git a/bun.lock b/bun.lock index 789dca49..b0896ebe 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "cartesi": "./dist/index.js", }, "dependencies": { + "@cartesi/client": "2.0.0-alpha.38", "@commander-js/extra-typings": "^15.0.0", "@inquirer/confirm": "^6.3.3", "@inquirer/core": "^12.0.4", @@ -137,10 +138,14 @@ "@cartesi/cli": ["@cartesi/cli@workspace:apps/cli"], + "@cartesi/client": ["@cartesi/client@2.0.0-alpha.38", "", { "dependencies": { "@cartesi/rpc": "2.0.0-alpha.27", "abitype": "^1.3.0", "p-retry": "^8.0.1" }, "peerDependencies": { "viem": "^2.0.0" } }, "sha512-XtYPLIKDvJClbl8Sb9/Mx2yK4Q7FJD8CPG3Gq2FCdNkgVZ0si7K8LhIFetnukiVIU1KF4aYJEtiIjgLizmnzRQ=="], + "@cartesi/devnet": ["@cartesi/devnet@workspace:packages/devnet"], "@cartesi/mock-verifying-paymaster": ["@cartesi/mock-verifying-paymaster@workspace:packages/mock-verifying-paymaster"], + "@cartesi/rpc": ["@cartesi/rpc@2.0.0-alpha.27", "", { "dependencies": { "json-rpc-2.0": "^1.8.0" } }, "sha512-mkZIELszlnulJYZglMuANuMel71uWcENnAl/LnNdpwaU6o8hq0o7f9y3JaBTwwjWOo73s2gOpyhc3cU1Kq/m+w=="], + "@cartesi/sdk": ["@cartesi/sdk@workspace:packages/sdk"], "@cartesi/wagmi-plugin": ["@cartesi/wagmi-plugin@1.0.0-alpha.7", "", { "dependencies": { "abitype": "^1.3.0", "modern-tar": "^0.8.4" }, "peerDependencies": { "@wagmi/cli": "^2.10.0", "viem": "^2.0.0" } }, "sha512-oVgBBMxuEd/xa5OSxToGjRf7nOA9OyjXLmCyH8V19DjZ+k9GNFFfZSDsk7bbDHtia1Vnq4nl6aNU6dU0K7po3w=="], @@ -797,6 +802,8 @@ "json-parse-better-errors": ["json-parse-better-errors@1.0.2", "", {}, "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="], + "json-rpc-2.0": ["json-rpc-2.0@1.8.1", "", {}, "sha512-BmN42JKn6FI36pcMMzHCXQhpKNEfbeqgSIcUYHKnifq0/4Ey6PDinovwUOgajT7NsgUtpWdDXaCWlpI4LHXN3g=="], + "json-schema-ref-resolver": ["json-schema-ref-resolver@1.0.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3" } }, "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], From dc2ecebd2a8e1b424e3af05af2d8047c04c35b3a Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 1 Oct 2026 21:28:27 +0100 Subject: [PATCH 02/25] feat(cli): add helpers to run rollups node tools in the node container --- apps/cli/src/exec/rollups.ts | 334 ++++++++++++++++++++++- apps/cli/tests/unit/exec/rollups.test.ts | 219 +++++++++++++++ 2 files changed, 552 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index 662ed12e..073b853b 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -1,5 +1,6 @@ import chalk from "chalk"; -import { execa } from "execa"; +import { ExecaError, execa } from "execa"; +import { dirname } from "node:path/posix"; import { Listr, type ListrTask } from "listr2"; import pRetry, { AbortError } from "p-retry"; import { @@ -10,6 +11,8 @@ import { getAddress, hexToNumber, http, + isHash, + toHex, } from "viem"; import { stringify } from "yaml"; import { @@ -711,3 +714,332 @@ export const getAnvilNodeInfo = async (options: { projectName: string }) => { ]); return JSON.parse(stdout); }; + +/** + * Path of the machine template inside the rollups node container, as + * published by `cartesi run` + */ +export const NODE_TEMPLATE_PATH = + "/var/lib/cartesi-rollups-node/snapshots/image"; + +/** + * Transaction options shared by the cartesi-rollups-cli commands that send a + * transaction. Each one is forwarded only when set. + */ +export type TransactionOptions = { + json?: boolean; + wait?: boolean; + waitTimeout?: string; + yes?: boolean; +}; + +export const transactionArgs = (options: TransactionOptions): string[] => { + const { json, wait, waitTimeout, yes } = options; + const args: string[] = []; + if (yes) { + args.push("--yes"); + } + if (json) { + args.push("--json"); + } + if (wait === false) { + args.push("--no-wait"); + } + if (waitTimeout) { + args.push("--wait-timeout", waitTimeout); + } + return args; +}; + +/** + * Build the docker arguments to run a command inside the rollups node + * container. Without a terminal attached docker cannot allocate a TTY, so it + * must be disabled. + */ +export const nodeExecArgs = (options: { + accountIndex?: number; + command: string[]; + interactive: boolean; + projectName: string; +}): string[] => { + const { accountIndex, command, interactive, projectName } = options; + const args = ["compose", "--project-name", projectName, "exec"]; + if (!interactive) { + args.push("-T"); + } + if (accountIndex !== undefined) { + args.push("-e", `CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=${accountIndex}`); + } + return [...args, "rollups_node", ...command]; +}; + +/** + * Run a command inside the rollups node container with the terminal attached, + * so its prompts and output reach the user directly + */ +export const runNodeCommand = (options: { + accountIndex?: number; + command: string[]; + projectName: string; +}) => + execa( + "docker", + nodeExecArgs({ ...options, interactive: !!process.stdin.isTTY }), + { stdio: "inherit" }, + ); + +/** + * Run a command inside the rollups node container and capture its output + * @returns stdout of the command + */ +export const execNodeCommand = async (options: { + command: string[]; + input?: string; + projectName: string; +}): Promise => { + const { input, ...rest } = options; + try { + const { stdout } = await execa( + "docker", + nodeExecArgs({ ...rest, interactive: false }), + { input }, + ); + return stdout; + } catch (error: unknown) { + if (error instanceof ExecaError) { + throw new Error( + String(error.stderr ?? "").trim() || error.shortMessage, + ); + } + throw error; + } +}; + +/** + * Write content to a fresh temporary file inside the rollups node container, + * so the node tools can read it + * @returns path of the file inside the container + */ +export const writeNodeTempFile = async (options: { + content: string; + projectName: string; +}): Promise => { + const { content, projectName } = options; + const stdout = await execNodeCommand({ + command: ["sh", "-c", 'f=$(mktemp) && cat > "$f" && echo "$f"'], + input: content, + projectName, + }); + return stdout.trim(); +}; + +/** + * Remove a file or directory from the rollups node container + */ +export const removeNodePath = (options: { + path: string; + projectName: string; +}) => + execNodeCommand({ + command: ["rm", "-rf", options.path], + projectName: options.projectName, + }); + +/** + * Check whether a path exists inside the rollups node container + */ +export const nodePathExists = async (options: { + path: string; + projectName: string; +}): Promise => { + const { exitCode } = await execa( + "docker", + nodeExecArgs({ + command: ["test", "-e", options.path], + interactive: false, + projectName: options.projectName, + }), + { reject: false }, + ); + return exitCode === 0; +}; + +/** + * Report the failure of a command run with the terminal attached. The command + * already printed its error, so only its exit code is propagated. + */ +export const handleNodeCommandError = (error: unknown) => { + if (error instanceof ExecaError) { + process.exitCode = error.exitCode ?? 1; + return; + } + throw error; +}; + +/** + * Layout of the accounts drive, as configured in the application contract + */ +export type AccountsDriveConfig = { + accountsDriveStartIndex: bigint; + log2LeavesPerAccount: bigint; + log2MaxNumOfAccounts: bigint; +}; + +export const replayArgs = (options: { + application: Address; + epochIndex: bigint; + store: string; +}): string[] => [ + "cartesi-rollups-machine-tool", + "replay", + "--template", + NODE_TEMPLATE_PATH, + "--application", + options.application, + "--to-epoch", + options.epochIndex.toString(), + "--store", + options.store, +]; + +export const proveAccountsDriveArgs = (options: { + account: Address; + driveConfig: AccountsDriveConfig; + outDir: string; + snapshot: string; +}): string[] => { + const { account, driveConfig, outDir, snapshot } = options; + return [ + "cartesi-rollups-machine-tool", + "prove", + "accounts-drive", + "--snapshot", + snapshot, + "--accounts-drive-start-index", + toHex(driveConfig.accountsDriveStartIndex), + "--log2-max-num-of-accounts", + toHex(driveConfig.log2MaxNumOfAccounts), + "--log2-leaves-per-account", + toHex(driveConfig.log2LeavesPerAccount), + "--account", + account, + "--out-drive-root-proof", + `${outDir}/drive-root-proof.json`, + "--out-withdraw-proof", + `${outDir}/withdraw-proof.json`, + ]; +}; + +export type ReplaySummary = { + machineRoot: Hash; + processedInputs: number; + store: string; +}; + +export type ProveSummary = { + accountIndex: bigint; + accountsDriveMerkleRoot: Hash; + driveRootProofFile: string; + machineRoot: Hash; + withdrawProofFile: string; +}; + +/** + * The machine tool prints a single JSON object as its last line of output + */ +const parseSummary = (stdout: string): Record => { + const line = stdout.trim().split("\n").pop() ?? ""; + const summary = JSON.parse(line); + if (typeof summary !== "object" || summary === null) { + throw new Error(`Unexpected machine tool output: ${stdout}`); + } + return summary; +}; + +const requireHash = (summary: Record, key: string): Hash => { + const value = summary[key]; + if (typeof value !== "string" || !isHash(value)) { + throw new Error(`Invalid ${key} in machine tool output: ${value}`); + } + return value; +}; + +const requireString = ( + summary: Record, + key: string, +): string => { + const value = summary[key]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Missing ${key} in machine tool output`); + } + return value; +}; + +export const parseReplaySummary = (stdout: string): ReplaySummary => { + const summary = parseSummary(stdout); + const processedInputs = summary.processed_inputs; + if (typeof processedInputs !== "number") { + throw new Error("Missing processed_inputs in machine tool output"); + } + return { + machineRoot: requireHash(summary, "machine_root"), + processedInputs, + store: requireString(summary, "store"), + }; +}; + +export const parseProveSummary = (stdout: string): ProveSummary => { + const summary = parseSummary(stdout); + return { + accountIndex: BigInt(requireString(summary, "account_index")), + accountsDriveMerkleRoot: requireHash( + summary, + "accounts_drive_merkle_root", + ), + driveRootProofFile: requireString(summary, "drive_root_proof_file"), + machineRoot: requireHash(summary, "machine_root"), + withdrawProofFile: requireString(summary, "withdraw_proof_file"), + }; +}; + +/** + * Replay the accepted inputs of an application up to an epoch, storing the + * resulting machine inside the rollups node container + */ +export const replayMachine = async (options: { + application: Address; + epochIndex: bigint; + projectName: string; + store: string; +}): Promise => { + const { projectName, store } = options; + await execNodeCommand({ + command: ["mkdir", "-p", dirname(store)], + projectName, + }); + const stdout = await execNodeCommand({ + command: replayArgs(options), + projectName, + }); + return parseReplaySummary(stdout); +}; + +/** + * Generate the accounts drive root proof and the withdraw proof of an account + * from a stored machine inside the rollups node container + */ +export const proveAccountsDrive = async (options: { + account: Address; + driveConfig: AccountsDriveConfig; + outDir: string; + projectName: string; + snapshot: string; +}): Promise => { + const { outDir, projectName } = options; + await execNodeCommand({ command: ["mkdir", "-p", outDir], projectName }); + const stdout = await execNodeCommand({ + command: proveAccountsDriveArgs(options), + projectName, + }); + return parseProveSummary(stdout); +}; diff --git a/apps/cli/tests/unit/exec/rollups.test.ts b/apps/cli/tests/unit/exec/rollups.test.ts index e9a153af..129ed622 100644 --- a/apps/cli/tests/unit/exec/rollups.test.ts +++ b/apps/cli/tests/unit/exec/rollups.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from "bun:test"; +import { toHex } from "viem"; import { buildDeployApplicationArgs, + NODE_TEMPLATE_PATH, + nodeExecArgs, parseApplications, + parseProveSummary, + parseReplaySummary, + proveAccountsDriveArgs, + replayArgs, + transactionArgs, } from "../../../src/exec/rollups"; /** A single entry as `cartesi-rollups-cli app list` prints it. */ @@ -170,3 +178,214 @@ describe("buildDeployApplicationArgs", () => { expect(args.at(-1)).toBe("--json"); }); }); + +describe("transactionArgs", () => { + it("should forward nothing by default", () => { + expect(transactionArgs({})).toEqual([]); + }); + + it("should forward each option that is set", () => { + expect( + transactionArgs({ + json: true, + wait: false, + waitTimeout: "5m", + yes: true, + }), + ).toEqual(["--yes", "--json", "--no-wait", "--wait-timeout", "5m"]); + }); + + it("should not forward --no-wait when waiting", () => { + expect(transactionArgs({ wait: true })).toEqual([]); + }); +}); + +describe("nodeExecArgs", () => { + it("should exec the command in the rollups node service", () => { + expect( + nodeExecArgs({ + command: ["cartesi-rollups-cli", "app", "list"], + interactive: true, + projectName: "dapp", + }), + ).toEqual([ + "compose", + "--project-name", + "dapp", + "exec", + "rollups_node", + "cartesi-rollups-cli", + "app", + "list", + ]); + }); + + it("should disable the TTY when not interactive", () => { + expect( + nodeExecArgs({ + command: ["true"], + interactive: false, + projectName: "dapp", + }), + ).toEqual([ + "compose", + "--project-name", + "dapp", + "exec", + "-T", + "rollups_node", + "true", + ]); + }); + + it("should select the signer account index", () => { + expect( + nodeExecArgs({ + accountIndex: 1, + command: ["true"], + interactive: true, + projectName: "dapp", + }), + ).toEqual([ + "compose", + "--project-name", + "dapp", + "exec", + "-e", + "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=1", + "rollups_node", + "true", + ]); + }); +}); + +const application = "0x1234567890abcdef1234567890abcdef12345678"; +const account = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const hash = (n: number) => toHex(n, { size: 32 }); + +describe("replayArgs", () => { + it("should replay the published template up to the epoch", () => { + expect( + replayArgs({ application, epochIndex: 108n, store: "/tmp/s" }), + ).toEqual([ + "cartesi-rollups-machine-tool", + "replay", + "--template", + NODE_TEMPLATE_PATH, + "--application", + application, + "--to-epoch", + "108", + "--store", + "/tmp/s", + ]); + }); +}); + +describe("proveAccountsDriveArgs", () => { + it("should pass the accounts drive layout in hex", () => { + expect( + proveAccountsDriveArgs({ + account, + driveConfig: { + accountsDriveStartIndex: 0x300n, + log2LeavesPerAccount: 0n, + log2MaxNumOfAccounts: 17n, + }, + outDir: "/tmp/out", + snapshot: "/tmp/s", + }), + ).toEqual([ + "cartesi-rollups-machine-tool", + "prove", + "accounts-drive", + "--snapshot", + "/tmp/s", + "--accounts-drive-start-index", + "0x300", + "--log2-max-num-of-accounts", + "0x11", + "--log2-leaves-per-account", + "0x0", + "--account", + account, + "--out-drive-root-proof", + "/tmp/out/drive-root-proof.json", + "--out-withdraw-proof", + "/tmp/out/withdraw-proof.json", + ]); + }); +}); + +describe("parseReplaySummary", () => { + const summary = { + processed_inputs: 3, + last_input_index: "0x2", + machine_root: hash(1), + store: "/tmp/s", + }; + + it("should parse the summary", () => { + expect(parseReplaySummary(JSON.stringify(summary))).toEqual({ + machineRoot: hash(1), + processedInputs: 3, + store: "/tmp/s", + }); + }); + + it("should read the summary from the last line", () => { + expect( + parseReplaySummary(`log line\n${JSON.stringify(summary)}\n`) + .machineRoot, + ).toBe(hash(1)); + }); + + it("should reject a missing machine root", () => { + expect(() => + parseReplaySummary( + JSON.stringify({ ...summary, machine_root: undefined }), + ), + ).toThrow("machine_root"); + }); + + it("should reject a machine root that is not a hash", () => { + expect(() => + parseReplaySummary( + JSON.stringify({ ...summary, machine_root: "0x1234" }), + ), + ).toThrow("machine_root"); + }); + + it("should reject malformed output", () => { + expect(() => parseReplaySummary("not json")).toThrow(); + }); +}); + +describe("parseProveSummary", () => { + const summary = { + account, + account_index: "0x2a", + accounts_drive_merkle_root: hash(2), + machine_root: hash(1), + drive_root_proof_file: "/tmp/out/drive-root-proof.json", + withdraw_proof_file: "/tmp/out/withdraw-proof.json", + }; + + it("should parse the summary", () => { + expect(parseProveSummary(JSON.stringify(summary))).toEqual({ + accountIndex: 42n, + accountsDriveMerkleRoot: hash(2), + driveRootProofFile: "/tmp/out/drive-root-proof.json", + machineRoot: hash(1), + withdrawProofFile: "/tmp/out/withdraw-proof.json", + }); + }); + + it("should reject a missing withdraw proof file", () => { + expect(() => + parseProveSummary( + JSON.stringify({ ...summary, withdraw_proof_file: undefined }), + ), + ).toThrow("withdraw_proof_file"); + }); +}); From 3c97d7c3a67430060be26053ec947dc1d9c1b7dc Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 1 Oct 2026 21:29:59 +0100 Subject: [PATCH 03/25] feat(cli): add foreclose command signing as the application guardian --- apps/cli/src/commands/foreclose.ts | 90 ++++++++++++++++++++++++++++++ apps/cli/src/compose/node.ts | 10 +++- apps/cli/src/index.ts | 2 + apps/cli/src/node.ts | 55 +++++++++++++++++- apps/cli/src/wallet.ts | 33 ++++++++++- apps/cli/tests/unit/node.test.ts | 15 +++++ apps/cli/tests/unit/wallet.test.ts | 44 +++++++++++++++ 7 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 apps/cli/src/commands/foreclose.ts create mode 100644 apps/cli/tests/unit/node.test.ts create mode 100644 apps/cli/tests/unit/wallet.test.ts diff --git a/apps/cli/src/commands/foreclose.ts b/apps/cli/src/commands/foreclose.ts new file mode 100644 index 00000000..b7fb291e --- /dev/null +++ b/apps/cli/src/commands/foreclose.ts @@ -0,0 +1,90 @@ +import { Command, Option } from "@commander-js/extra-typings"; +import chalk from "chalk"; +import { isAddressEqual, zeroAddress } from "viem"; +import { getProjectName } from "../base.js"; +import { + handleNodeCommandError, + runNodeCommand, + transactionArgs, +} from "../exec/rollups.js"; +import { + getNodeClient, + parseAccountIndex, + resolveNodeApplication, +} from "../node.js"; +import { findMnemonicAccountIndex, getNodeMnemonic } from "../wallet.js"; + +export const createForecloseCommand = () => { + return new Command("foreclose") + .description( + "Forecloses the application, so users can withdraw their funds", + ) + .configureHelp({ showGlobalOptions: true }) + .option("--application
", "application address") + .option( + "--project-name ", + "name of project (used by docker compose and cartesi-rollups-node)", + ) + .addOption( + new Option( + "--account-index ", + "index of the guardian account in the node mnemonic (default: derived from the application guardian)", + ).argParser(parseAccountIndex), + ) + .option("-y, --yes", "skip the confirmation prompt") + .option("--json", "print the result as JSON") + .option( + "--no-wait", + "return after broadcast without waiting for the receipt", + ) + .option( + "--wait-timeout ", + "maximum time to wait for the receipt (e.g. 30s, 5m)", + ) + .action(async (options) => { + const projectName = getProjectName(options); + const application = await resolveNodeApplication({ + application: options.application, + projectName, + }); + + // the guardian is the only account allowed to foreclose + let accountIndex = options.accountIndex; + if (accountIndex === undefined) { + const client = await getNodeClient({ projectName }); + const { withdrawalConfig } = await client.getApplication({ + application, + }); + const { guardian } = withdrawalConfig; + if (isAddressEqual(guardian, zeroAddress)) { + throw new Error( + `Application ${chalk.cyan(application)} was deployed without a withdrawal config and cannot be foreclosed`, + ); + } + accountIndex = findMnemonicAccountIndex({ + address: guardian, + mnemonic: getNodeMnemonic(), + }); + if (accountIndex === undefined) { + throw new Error( + `Guardian ${chalk.cyan(guardian)} is not derived from the node mnemonic, use ${chalk.cyan("--account-index")}`, + ); + } + } + + try { + await runNodeCommand({ + accountIndex, + command: [ + "cartesi-rollups-cli", + "foreclose", + application, + ...transactionArgs(options), + ], + projectName, + }); + } catch (error: unknown) { + handleNodeCommandError(error); + } + }); +}; diff --git a/apps/cli/src/compose/node.ts b/apps/cli/src/compose/node.ts index 2ad22f83..4c6e45c3 100644 --- a/apps/cli/src/compose/node.ts +++ b/apps/cli/src/compose/node.ts @@ -8,6 +8,12 @@ import { import type { ComposeFile, Config, Service } from "../types/compose.js"; import { DEFAULT_HEALTHCHECK } from "./common.js"; +/** + * Mnemonic of the devnet test accounts, used by the node to sign transactions + */ +export const DEVNET_MNEMONIC = + "test test test test test test test test test test test junk"; + export type ServiceOptions = { cpus?: number; databaseHost?: string; @@ -106,9 +112,7 @@ const service = (options: ServiceOptions): Service => { const defaultBlock = options.defaultBlock ?? "latest"; const imageTag = options.imageTag ?? "latest"; const logLevel = options.logLevel ?? "info"; - const mnemonic = - options.mnemonic ?? - "test test test test test test test test test test test junk"; + const mnemonic = options.mnemonic ?? DEVNET_MNEMONIC; const prt = options.prt ?? false; const chainId = (options.forkChainId ?? 31337) as keyof typeof daveAppFactoryAddress; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 1045a545..6fd979b2 100755 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -7,6 +7,7 @@ import { createCreateCommand } from "./commands/create.js"; import { createDeployCommand } from "./commands/deploy.js"; import { createDepositCommand } from "./commands/deposit.js"; import { createDoctorCommand } from "./commands/doctor.js"; +import { createForecloseCommand } from "./commands/foreclose.js"; import { createHashCommand } from "./commands/hash.js"; import { createLogsCommand } from "./commands/logs.js"; import { createRunCommand } from "./commands/run.js"; @@ -35,6 +36,7 @@ const program = new Command() .addCommand(createDeployCommand(), { hidden: true }) .addCommand(createDepositCommand()) .addCommand(createDoctorCommand()) + .addCommand(createForecloseCommand()) .addCommand(createLogsCommand()) .addCommand(createHashCommand()) .addCommand(createRunCommand()) diff --git a/apps/cli/src/node.ts b/apps/cli/src/node.ts index 54e1ab81..daf678cf 100644 --- a/apps/cli/src/node.ts +++ b/apps/cli/src/node.ts @@ -3,9 +3,12 @@ import { type CartesiPublicClient, createCartesiPublicClient, } from "@cartesi/client"; +import { InvalidArgumentError } from "@commander-js/extra-typings"; +import chalk from "chalk"; import pRetry from "p-retry"; -import { type Address, http } from "viem"; -import { getProjectPort } from "./exec/rollups.js"; +import { type Address, getAddress, http, isAddress } from "viem"; +import { getServiceState } from "./base.js"; +import { getApplicationAddress, getProjectPort } from "./exec/rollups.js"; /** * Create a client for the JSON-RPC API of the local rollups node @@ -51,3 +54,51 @@ export const waitForApplication = async (options: { }, ); }; + +/** + * Resolve the application a command acts on, making sure the local + * environment is running + * @returns address of the given application, or of the one deployed for the current machine + */ +export const resolveNodeApplication = async (options: { + application?: string; + projectName: string; +}): Promise
=> { + const { application, projectName } = options; + + const state = await getServiceState({ + projectName, + service: "rollups_node", + }); + if (state !== "running") { + throw new Error( + `${chalk.cyan(projectName)} is not running, use ${chalk.cyan("cartesi run")}`, + ); + } + + if (application) { + if (!isAddress(application)) { + throw new Error(`Invalid application address ${application}`); + } + return getAddress(application); + } + + const address = await getApplicationAddress({ projectName }); + if (!address) { + throw new Error( + `No application deployed for the current machine, use ${chalk.cyan("--application")}`, + ); + } + return address; +}; + +/** + * Commander parser of a mnemonic account index + */ +export const parseAccountIndex = (value: string): number => { + const index = Number(value); + if (!Number.isSafeInteger(index) || index < 0) { + throw new InvalidArgumentError("Not a valid account index."); + } + return index; +}; diff --git a/apps/cli/src/wallet.ts b/apps/cli/src/wallet.ts index 0de7c6da..52a8a73d 100644 --- a/apps/cli/src/wallet.ts +++ b/apps/cli/src/wallet.ts @@ -1,13 +1,17 @@ import input from "@inquirer/input"; import { + type Address, createTestClient, defineChain, http, + isAddressEqual, publicActions, walletActions, } from "viem"; +import { mnemonicToAccount } from "viem/accounts"; import { anvil } from "viem/chains"; -import { getProjectName } from "./base.js"; +import { getCartesiEnvironmentVariables, getProjectName } from "./base.js"; +import { DEVNET_MNEMONIC } from "./compose/node.js"; import { PREFERRED_PORT } from "./config.js"; import { getProjectPort } from "./exec/rollups.js"; @@ -55,3 +59,30 @@ export const connect = async (options: { .extend(walletActions); return client; }; + +/** + * Mnemonic the rollups node signs with: the one passed down from the host + * environment, or the devnet one + */ +export const getNodeMnemonic = (): string => + getCartesiEnvironmentVariables().CARTESI_AUTH_MNEMONIC ?? DEVNET_MNEMONIC; + +/** + * Find the index of the account derived from a mnemonic that has the given + * address + * @returns the account index, or undefined if none of the first `max` accounts matches + */ +export const findMnemonicAccountIndex = (options: { + address: Address; + max?: number; + mnemonic: string; +}): number | undefined => { + const { address, max = 20, mnemonic } = options; + for (let addressIndex = 0; addressIndex < max; addressIndex++) { + const account = mnemonicToAccount(mnemonic, { addressIndex }); + if (isAddressEqual(account.address, address)) { + return addressIndex; + } + } + return undefined; +}; diff --git a/apps/cli/tests/unit/node.test.ts b/apps/cli/tests/unit/node.test.ts new file mode 100644 index 00000000..9c1967b9 --- /dev/null +++ b/apps/cli/tests/unit/node.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "bun:test"; +import { parseAccountIndex } from "../../src/node"; + +describe("parseAccountIndex", () => { + it("should parse a non-negative integer", () => { + expect(parseAccountIndex("0")).toBe(0); + expect(parseAccountIndex("3")).toBe(3); + }); + + it("should reject anything else", () => { + expect(() => parseAccountIndex("-1")).toThrow(); + expect(() => parseAccountIndex("1.5")).toThrow(); + expect(() => parseAccountIndex("one")).toThrow(); + }); +}); diff --git a/apps/cli/tests/unit/wallet.test.ts b/apps/cli/tests/unit/wallet.test.ts new file mode 100644 index 00000000..22a03f72 --- /dev/null +++ b/apps/cli/tests/unit/wallet.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "bun:test"; +import { DEVNET_MNEMONIC } from "../../src/compose/node"; +import { findMnemonicAccountIndex } from "../../src/wallet"; + +describe("findMnemonicAccountIndex", () => { + it("should find the index of a devnet account", () => { + expect( + findMnemonicAccountIndex({ + address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + mnemonic: DEVNET_MNEMONIC, + }), + ).toBe(1); + }); + + it("should match regardless of the address checksum", () => { + expect( + findMnemonicAccountIndex({ + address: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", + mnemonic: DEVNET_MNEMONIC, + }), + ).toBe(1); + }); + + it("should return undefined for an address not derived from the mnemonic", () => { + expect( + findMnemonicAccountIndex({ + address: "0x1234567890abcdef1234567890abcdef12345678", + max: 3, + mnemonic: DEVNET_MNEMONIC, + }), + ).toBeUndefined(); + }); + + it("should only look at the first accounts", () => { + // 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC is the account at index 2 + expect( + findMnemonicAccountIndex({ + address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + max: 2, + mnemonic: DEVNET_MNEMONIC, + }), + ).toBeUndefined(); + }); +}); From 6f9efe02d776c7b4a82a5535ec6a3f6d335500c6 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 1 Oct 2026 21:30:24 +0100 Subject: [PATCH 04/25] feat(cli): add refund command --- apps/cli/src/commands/refund.ts | 142 ++++++++++++++++++++++++++++++++ apps/cli/src/index.ts | 2 + 2 files changed, 144 insertions(+) create mode 100644 apps/cli/src/commands/refund.ts diff --git a/apps/cli/src/commands/refund.ts b/apps/cli/src/commands/refund.ts new file mode 100644 index 00000000..fbe18623 --- /dev/null +++ b/apps/cli/src/commands/refund.ts @@ -0,0 +1,142 @@ +import { + Command, + InvalidArgumentError, + Option, +} from "@commander-js/extra-typings"; +import chalk from "chalk"; +import fs from "fs-extra"; +import { type Address, type Hex, isHex } from "viem"; +import { getProjectName } from "../base.js"; +import { + handleNodeCommandError, + removeNodePath, + runNodeCommand, + transactionArgs, + writeNodeTempFile, +} from "../exec/rollups.js"; +import { + getNodeClient, + parseAccountIndex, + resolveNodeApplication, +} from "../node.js"; + +/** + * Commander parser of an input index, in decimal or 0x-prefixed hexadecimal + */ +const parseInputIndex = (value: string): bigint => { + if (!/^(0x[0-9a-fA-F]+|[0-9]+)$/.test(value)) { + throw new InvalidArgumentError("Not a valid input index."); + } + return BigInt(value); +}; + +/** + * Get the complete InputAdded.input bytes of the deposit to refund, from a + * file or from the node + */ +const getInputData = async (options: { + application: Address; + inputFile?: string; + inputIndex: bigint; + projectName: string; +}): Promise => { + const { application, inputFile, inputIndex, projectName } = options; + + if (inputFile) { + const data = (await fs.readFile(inputFile, "utf-8")).trim(); + if (!isHex(data) || data.length <= 2) { + throw new Error( + `${inputFile} must contain the 0x-prefixed hexadecimal input bytes`, + ); + } + return data; + } + + const client = await getNodeClient({ projectName }); + try { + const input = await client.getInput({ application, inputIndex }); + return input.rawData; + } catch (error: unknown) { + throw new Error( + `Input ${inputIndex} not found in the node, use ${chalk.cyan("--input-file")} with the InputAdded.input bytes`, + { cause: error }, + ); + } +}; + +export const createRefundCommand = () => { + return new Command("refund") + .description( + "Refunds a deposit that was not finalized before the application was foreclosed", + ) + .configureHelp({ showGlobalOptions: true }) + .argument( + "", + "index of the deposit input", + parseInputIndex, + ) + .option("--application
", "application address") + .option( + "--project-name ", + "name of project (used by docker compose and cartesi-rollups-node)", + ) + .option( + "--input-file ", + "file with the 0x-prefixed InputAdded.input bytes (default: read from the node)", + ) + .addOption( + new Option( + "--account-index ", + "index of the account in the node mnemonic paying for gas", + ).argParser(parseAccountIndex), + ) + .option("-y, --yes", "skip the confirmation prompt") + .option("--json", "print the result as JSON") + .option( + "--no-wait", + "return after broadcast without waiting for the receipt", + ) + .option( + "--wait-timeout ", + "maximum time to wait for the receipt (e.g. 30s, 5m)", + ) + .action(async (inputIndex, options) => { + const projectName = getProjectName(options); + const application = await resolveNodeApplication({ + application: options.application, + projectName, + }); + + const data = await getInputData({ + application, + inputFile: options.inputFile, + inputIndex, + projectName, + }); + + // the node tool reads the input bytes from a file in its container + const path = await writeNodeTempFile({ + content: `${data}\n`, + projectName, + }); + try { + await runNodeCommand({ + accountIndex: options.accountIndex, + command: [ + "cartesi-rollups-cli", + "refund", + application, + inputIndex.toString(), + "--input-file", + path, + ...transactionArgs(options), + ], + projectName, + }); + } catch (error: unknown) { + handleNodeCommandError(error); + } finally { + await removeNodePath({ path, projectName }); + } + }); +}; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 6fd979b2..8e097833 100755 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -10,6 +10,7 @@ import { createDoctorCommand } from "./commands/doctor.js"; import { createForecloseCommand } from "./commands/foreclose.js"; import { createHashCommand } from "./commands/hash.js"; import { createLogsCommand } from "./commands/logs.js"; +import { createRefundCommand } from "./commands/refund.js"; import { createRunCommand } from "./commands/run.js"; import { createSendCommand } from "./commands/send.js"; import { createShellCommand } from "./commands/shell.js"; @@ -39,6 +40,7 @@ const program = new Command() .addCommand(createForecloseCommand()) .addCommand(createLogsCommand()) .addCommand(createHashCommand()) + .addCommand(createRefundCommand()) .addCommand(createRunCommand()) .addCommand(createSendCommand()) .addCommand(createShellCommand()) From a0ae450de97115b0a764f61f425ef30bdb0a1811 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 1 Oct 2026 21:31:52 +0100 Subject: [PATCH 05/25] feat(cli): add withdraw command generating proofs from the accepted epoch --- apps/cli/src/commands/withdraw.ts | 312 +++++++++++++++++++++++ apps/cli/src/exec/rollups.ts | 29 ++- apps/cli/src/index.ts | 4 +- apps/cli/src/node.ts | 33 --- apps/cli/tests/unit/exec/rollups.test.ts | 20 ++ 5 files changed, 356 insertions(+), 42 deletions(-) create mode 100644 apps/cli/src/commands/withdraw.ts diff --git a/apps/cli/src/commands/withdraw.ts b/apps/cli/src/commands/withdraw.ts new file mode 100644 index 00000000..54c08a26 --- /dev/null +++ b/apps/cli/src/commands/withdraw.ts @@ -0,0 +1,312 @@ +import type { CartesiPublicClient } from "@cartesi/client"; +import { Command, Option } from "@commander-js/extra-typings"; +import chalk from "chalk"; +import fs from "fs-extra"; +import ora from "ora"; +import type { Address, Hash } from "viem"; +import { getProjectName, parseAddress } from "../base.js"; +import { iApplicationAbi } from "../contracts.js"; +import { + execNodeCommand, + handleNodeCommandError, + nodePathExists, + proveAccountsDrive, + removeNodePath, + replayMachine, + runNodeCommand, + type TransactionOptions, + transactionArgs, + writeNodeTempFile, +} from "../exec/rollups.js"; +import { + getNodeClient, + parseAccountIndex, + resolveNodeApplication, +} from "../node.js"; +import { addressInput } from "../prompts.js"; +import { connect } from "../wallet.js"; + +/** + * Directory inside the rollups node container where replayed machines and + * proofs are kept, so further withdrawals of the same application reuse them + */ +const WORK_DIR = "/tmp/cartesi-withdraw"; + +/** + * Read the foreclosure state of the application from the chain. The node + * observes it some blocks later, which is too late for a withdrawal that + * follows another one. + */ +const readForeclosureState = async (options: { + application: Address; + projectName: string; +}) => { + const { application, projectName } = options; + const client = await connect({ projectName }); + const [foreclosed, [driveRootProven]] = await Promise.all([ + client.readContract({ + abi: iApplicationAbi, + address: application, + functionName: "isForeclosed", + }), + client.readContract({ + abi: iApplicationAbi, + address: application, + functionName: "getAccountsDriveMerkleRoot", + }), + ]); + return { driveRootProven, foreclosed }; +}; + +const isSameHash = (a: Hash, b: Hash) => a.toLowerCase() === b.toLowerCase(); + +/** + * Replay the application up to its last finalized epoch, unless a previous + * withdrawal already did it. Nothing is finalized after the foreclosure, so + * the replayed machine never changes. + * @returns path of the replayed machine inside the rollups node container + */ +const replayLastFinalizedEpoch = async (options: { + application: Address; + client: CartesiPublicClient; + projectName: string; +}): Promise<{ machineHash: Hash; store: string }> => { + const { application, client, projectName } = options; + const progress = ora("Looking up the last finalized epoch...").start(); + + let epochIndex: bigint; + try { + epochIndex = await client.getLastAcceptedEpochIndex({ application }); + } catch (error: unknown) { + progress.fail("No finalized epoch, there are no funds to withdraw"); + throw error; + } + const { machineHash } = await client.getEpoch({ application, epochIndex }); + if (!machineHash) { + progress.fail(); + throw new Error(`Epoch ${epochIndex} has no machine hash`); + } + + const store = `${WORK_DIR}/${application}/epoch-${epochIndex}`; + if (await nodePathExists({ path: store, projectName })) { + progress.succeed( + `Using the machine replayed up to epoch ${chalk.cyan(epochIndex)}`, + ); + return { machineHash, store }; + } + + // replay into a temporary path, so an interrupted replay is never reused + const tmp = `${store}.tmp`; + progress.text = `Replaying the inputs up to epoch ${chalk.cyan(epochIndex)}, this may take a while...`; + try { + await removeNodePath({ path: tmp, projectName }); + const replay = await replayMachine({ + application, + epochIndex, + projectName, + store: tmp, + }); + if (!isSameHash(replay.machineRoot, machineHash)) { + throw new Error( + `Replayed machine root ${replay.machineRoot} does not match the machine hash ${machineHash} of epoch ${epochIndex}`, + ); + } + await execNodeCommand({ command: ["mv", tmp, store], projectName }); + progress.succeed( + `Replayed ${chalk.cyan(replay.processedInputs)} inputs up to epoch ${chalk.cyan(epochIndex)}`, + ); + } catch (error: unknown) { + progress.fail("Failed to replay the application"); + await removeNodePath({ path: tmp, projectName }); + throw error; + } + return { machineHash, store }; +}; + +/** + * Generate the proofs of an account and prove the accounts drive root, if + * nobody did it yet + * @returns path of the withdraw proof inside the rollups node container + */ +const proveAccount = async (options: { + account: Address; + accountIndex?: number; + application: Address; + client: CartesiPublicClient; + driveRootProven: boolean; + projectName: string; + tx: TransactionOptions; +}): Promise => { + const { + account, + accountIndex, + application, + client, + driveRootProven, + projectName, + tx, + } = options; + const { withdrawalConfig } = await client.getApplication({ + application, + }); + + const { machineHash, store } = await replayLastFinalizedEpoch({ + application, + client, + projectName, + }); + + const progress = ora( + `Generating the proofs of ${chalk.cyan(account)}...`, + ).start(); + const proof = await proveAccountsDrive({ + account, + driveConfig: withdrawalConfig, + outDir: `${WORK_DIR}/${application}/${account}`, + projectName, + snapshot: store, + }).catch((error: unknown) => { + progress.fail(`Failed to generate the proofs of ${account}`); + throw error; + }); + if (!isSameHash(proof.machineRoot, machineHash)) { + progress.fail(); + throw new Error( + `Proof machine root ${proof.machineRoot} does not match the finalized machine hash ${machineHash}`, + ); + } + progress.succeed( + `Generated the proofs of ${chalk.cyan(account)} (account index ${chalk.cyan(proof.accountIndex)})`, + ); + + if (!driveRootProven) { + // the withdrawal depends on it, so always wait for the receipt, and + // keep stdout for the output of the withdrawal + await runNodeCommand({ + accountIndex, + command: [ + "cartesi-rollups-cli", + "prove-drive-root", + application, + "--proof-file", + proof.driveRootProofFile, + ...transactionArgs({ ...tx, json: false, wait: true }), + ], + projectName, + stdout: "stderr", + }); + } + + return proof.withdrawProofFile; +}; + +export const createWithdrawCommand = () => { + return new Command("withdraw") + .description( + "Withdraws the funds of an account from a foreclosed application", + ) + .configureHelp({ showGlobalOptions: true }) + .addOption( + new Option( + "--account
", + "account to withdraw the funds of", + ).argParser(parseAddress), + ) + .addOption( + new Option( + "--proof-file ", + "withdraw proof generated elsewhere, instead of generating it", + ).conflicts("account"), + ) + .option("--application
", "application address") + .option( + "--project-name ", + "name of project (used by docker compose and cartesi-rollups-node)", + ) + .addOption( + new Option( + "--account-index ", + "index of the account in the node mnemonic paying for gas", + ).argParser(parseAccountIndex), + ) + .option("-y, --yes", "skip the confirmation prompt") + .option("--json", "print the result as JSON") + .option( + "--no-wait", + "return after broadcast without waiting for the receipt", + ) + .option( + "--wait-timeout ", + "maximum time to wait for the receipt (e.g. 30s, 5m)", + ) + .action(async (options) => { + const { accountIndex, proofFile } = options; + const projectName = getProjectName(options); + const application = await resolveNodeApplication({ + application: options.application, + projectName, + }); + const client = await getNodeClient({ projectName }); + + const { driveRootProven, foreclosed } = await readForeclosureState({ + application, + projectName, + }); + if (!foreclosed) { + throw new Error( + `Application ${chalk.cyan(application)} is not foreclosed, use ${chalk.cyan("cartesi foreclose")} first`, + ); + } + + let withdrawProofFile: string; + let tmpProofFile: string | undefined; + try { + if (proofFile) { + if (!driveRootProven) { + throw new Error( + `The accounts drive root of ${application} is not proven yet, use ${chalk.cyan("--account")} so it can be proven`, + ); + } + tmpProofFile = await writeNodeTempFile({ + content: await fs.readFile(proofFile, "utf-8"), + projectName, + }); + withdrawProofFile = tmpProofFile; + } else { + const account = + options.account ?? + (await addressInput({ + message: "Account to withdraw the funds of", + })); + withdrawProofFile = await proveAccount({ + account, + accountIndex, + application, + client, + driveRootProven, + projectName, + tx: options, + }); + } + + await runNodeCommand({ + accountIndex, + command: [ + "cartesi-rollups-cli", + "withdraw", + application, + "--proof-file", + withdrawProofFile, + ...transactionArgs(options), + ], + projectName, + }); + } catch (error: unknown) { + handleNodeCommandError(error); + } finally { + if (tmpProofFile) { + await removeNodePath({ path: tmpProofFile, projectName }); + } + } + }); +}; diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index 073b853b..816ec7a0 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -761,8 +761,10 @@ export const nodeExecArgs = (options: { command: string[]; interactive: boolean; projectName: string; + workdir?: string; }): string[] => { - const { accountIndex, command, interactive, projectName } = options; + const { accountIndex, command, interactive, projectName, workdir } = + options; const args = ["compose", "--project-name", projectName, "exec"]; if (!interactive) { args.push("-T"); @@ -770,22 +772,31 @@ export const nodeExecArgs = (options: { if (accountIndex !== undefined) { args.push("-e", `CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=${accountIndex}`); } + if (workdir) { + args.push("--workdir", workdir); + } return [...args, "rollups_node", ...command]; }; /** * Run a command inside the rollups node container with the terminal attached, - * so its prompts and output reach the user directly + * so its prompts and output reach the user directly. Its output can be sent to + * stderr to keep stdout for the output of a later command. */ export const runNodeCommand = (options: { accountIndex?: number; command: string[]; projectName: string; + stdout?: "stdout" | "stderr"; }) => execa( "docker", nodeExecArgs({ ...options, interactive: !!process.stdin.isTTY }), - { stdio: "inherit" }, + { + stdin: "inherit", + stdout: options.stdout === "stderr" ? 2 : "inherit", + stderr: "inherit", + }, ); /** @@ -796,6 +807,7 @@ export const execNodeCommand = async (options: { command: string[]; input?: string; projectName: string; + workdir?: string; }): Promise => { const { input, ...rest } = options; try { @@ -1004,7 +1016,8 @@ export const parseProveSummary = (stdout: string): ProveSummary => { /** * Replay the accepted inputs of an application up to an epoch, storing the - * resulting machine inside the rollups node container + * resulting machine inside the rollups node container. The machine writes the + * reports of the inputs to its working directory, which must be writable. */ export const replayMachine = async (options: { application: Address; @@ -1013,13 +1026,12 @@ export const replayMachine = async (options: { store: string; }): Promise => { const { projectName, store } = options; - await execNodeCommand({ - command: ["mkdir", "-p", dirname(store)], - projectName, - }); + const workdir = dirname(store); + await execNodeCommand({ command: ["mkdir", "-p", workdir], projectName }); const stdout = await execNodeCommand({ command: replayArgs(options), projectName, + workdir, }); return parseReplaySummary(stdout); }; @@ -1040,6 +1052,7 @@ export const proveAccountsDrive = async (options: { const stdout = await execNodeCommand({ command: proveAccountsDriveArgs(options), projectName, + workdir: outDir, }); return parseProveSummary(stdout); }; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 8e097833..3d7a8e8e 100755 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -15,6 +15,7 @@ import { createRunCommand } from "./commands/run.js"; import { createSendCommand } from "./commands/send.js"; import { createShellCommand } from "./commands/shell.js"; import { createStatusCommand } from "./commands/status.js"; +import { createWithdrawCommand } from "./commands/withdraw.js"; const splash = String.raw` . / \ @@ -44,7 +45,8 @@ const program = new Command() .addCommand(createRunCommand()) .addCommand(createSendCommand()) .addCommand(createShellCommand()) - .addCommand(createStatusCommand()); + .addCommand(createStatusCommand()) + .addCommand(createWithdrawCommand()); // Global error handling process.on("uncaughtException", (err) => { diff --git a/apps/cli/src/node.ts b/apps/cli/src/node.ts index daf678cf..8e387b86 100644 --- a/apps/cli/src/node.ts +++ b/apps/cli/src/node.ts @@ -1,11 +1,9 @@ import { - type Application, type CartesiPublicClient, createCartesiPublicClient, } from "@cartesi/client"; import { InvalidArgumentError } from "@commander-js/extra-typings"; import chalk from "chalk"; -import pRetry from "p-retry"; import { type Address, getAddress, http, isAddress } from "viem"; import { getServiceState } from "./base.js"; import { getApplicationAddress, getProjectPort } from "./exec/rollups.js"; @@ -24,37 +22,6 @@ export const getNodeClient = async (options: { }); }; -/** - * Wait until the node reports an application state that satisfies the - * predicate. The node observes chain events with some lag, so a state change - * caused by a transaction is not visible right after it is mined. - * @returns the application in the expected state - */ -export const waitForApplication = async (options: { - application: Address; - client: CartesiPublicClient; - message: string; - predicate: (application: Application) => boolean; - retries?: number; -}): Promise => { - const { application, client, message, predicate, retries = 20 } = options; - return pRetry( - async () => { - const app = await client.getApplication({ application }); - if (!predicate(app)) { - throw new Error(message); - } - return app; - }, - { - retries, - minTimeout: 500, - maxTimeout: 2_000, - factor: 1.2, - }, - ); -}; - /** * Resolve the application a command acts on, making sure the local * environment is running diff --git a/apps/cli/tests/unit/exec/rollups.test.ts b/apps/cli/tests/unit/exec/rollups.test.ts index 129ed622..050f65f9 100644 --- a/apps/cli/tests/unit/exec/rollups.test.ts +++ b/apps/cli/tests/unit/exec/rollups.test.ts @@ -201,6 +201,26 @@ describe("transactionArgs", () => { }); describe("nodeExecArgs", () => { + it("should run the command in the working directory", () => { + expect( + nodeExecArgs({ + command: ["true"], + interactive: true, + projectName: "dapp", + workdir: "/tmp/work", + }), + ).toEqual([ + "compose", + "--project-name", + "dapp", + "exec", + "--workdir", + "/tmp/work", + "rollups_node", + "true", + ]); + }); + it("should exec the command in the rollups node service", () => { expect( nodeExecArgs({ From 076b43983becfaa68b2e9a87a01596d370b89a88 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 1 Oct 2026 22:05:39 +0100 Subject: [PATCH 06/25] test(cli): cover foreclose, refund and withdraw against the sdk --- .../fixtures/erc20-withdrawal/Dockerfile | 36 ++ .../fixtures/erc20-withdrawal/dapp.sh | 303 ++++++++++++++++ .../integration/commands/recovery.test.ts | 338 ++++++++++++++++++ 3 files changed, 677 insertions(+) create mode 100644 apps/cli/tests/integration/commands/fixtures/erc20-withdrawal/Dockerfile create mode 100755 apps/cli/tests/integration/commands/fixtures/erc20-withdrawal/dapp.sh create mode 100644 apps/cli/tests/integration/commands/recovery.test.ts diff --git a/apps/cli/tests/integration/commands/fixtures/erc20-withdrawal/Dockerfile b/apps/cli/tests/integration/commands/fixtures/erc20-withdrawal/Dockerfile new file mode 100644 index 00000000..47582ef8 --- /dev/null +++ b/apps/cli/tests/integration/commands/fixtures/erc20-withdrawal/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker.io/docker/dockerfile:1 + +ARG UBUNTU_TAG=noble-20260610 +ARG APT_UPDATE_SNAPSHOT=20260701T030400Z +ARG MACHINE_GUEST_TOOLS_VERSION=0.18.0-test7 +ARG MACHINE_GUEST_TOOLS_SHA256SUM=e21bf93d4858e95fe9078ef436e9c13a6616125a7f65629c2fd571d72307f59a + +FROM --platform=linux/riscv64 ubuntu:${UBUNTU_TAG} + +ARG MACHINE_GUEST_TOOLS_VERSION +ARG MACHINE_GUEST_TOOLS_SHA256SUM +ADD --checksum=sha256:${MACHINE_GUEST_TOOLS_SHA256SUM} \ + https://github.com/cartesi/machine-guest-tools/releases/download/v${MACHINE_GUEST_TOOLS_VERSION}/machine-guest-tools_riscv64.deb \ + /tmp/machine-guest-tools_riscv64.deb + +ARG APT_UPDATE_SNAPSHOT +ARG DEBIAN_FRONTEND=noninteractive +RUN </dev/null +} + +report_text() { + printf '%s' "$1" | xxd -p -c 0 | { + read -r payload + report "$payload" + } +} + +reject_request() { + report "$1" + rollup reject +} + +accept_request() { + local status + + rm -f "$MERKLE_KEEP" + + if [[ -f "$MERKLE_FILE" ]]; then + cp -f "$MERKLE_FILE" "$MERKLE_KEEP" + fi + + rollup accept + status=$? + + # The stock rollup helper resets /tmp/merkle.dat after it receives the next + # advance request. The node compares against the cumulative output tree, so a + # shell dApp that invokes the helper once per operation must restore it. + if [[ -f "$MERKLE_KEEP" ]]; then + mv -f "$MERKLE_KEEP" "$MERKLE_FILE" + fi + return "$status" +} + +reverse_bytes() { + printf '%s' "$1" | fold -w2 | tac | tr -d '\n' +} + +uint64_be_to_dec() { + printf '%d' "0x$1" +} + +uint64_dec_to_le() { + reverse_bytes "$(printf '%016x' "$1")" +} + +hex_to_text() { + printf '%s' "$1" | xxd -r -p +} + +read_record() { + dd if="$ACCOUNT_DRIVE" bs="$ACCOUNT_SIZE" skip="$1" count=1 2>/dev/null | xxd -p -c "$ACCOUNT_SIZE" +} + +write_record() { + printf '%s' "$2" | xxd -r -p | dd of="$ACCOUNT_DRIVE" bs="$ACCOUNT_SIZE" seek="$1" count=1 conv=notrunc 2>/dev/null +} + +zero_record() { + write_record "$1" "$ZERO_RECORD" +} + +record_address() { + printf '%s' "${1:24:40}" +} + +record_balance() { + uint64_be_to_dec "$(reverse_bytes "${1:0:16}")" +} + +encode_record() { + local balance="$1" + local address="$2" + # This fixture limits arithmetic to positive int64 values. Store them + # zero-extended in the uint96 balance field required by LibUsdAccount. + printf '%s00000000%s' "$(uint64_dec_to_le "$balance")" "$address" +} + +find_account_index() { + local address="$1" + local i record + for ((i = 0; i < MAX_ACCOUNTS; i++)); do + record="$(read_record "$i")" + if [[ "$record" == "$ZERO_RECORD" ]]; then + printf '%d' "$i" + return 1 + fi + if [[ "$(record_address "$record")" == "$address" ]]; then + printf '%d' "$i" + return 0 + fi + done + return 2 +} + +last_account_index() { + local i record last=-1 + for ((i = 0; i < MAX_ACCOUNTS; i++)); do + record="$(read_record "$i")" + if [[ "$record" == "$ZERO_RECORD" ]]; then + printf '%d' "$last" + return 0 + fi + last="$i" + done + printf '%d' "$last" +} + +credit_account() { + local address="$1" + local amount="$2" + local idx status record balance new_balance + + idx="$(find_account_index "$address")" + status=$? + if [[ "$status" -eq 2 ]]; then + return 1 + fi + + if [[ "$status" -eq 0 ]]; then + record="$(read_record "$idx")" + balance="$(record_balance "$record")" + new_balance=$((balance + amount)) + else + new_balance="$amount" + fi + + if (( new_balance <= 0 )); then + return 1 + fi + write_record "$idx" "$(encode_record "$new_balance" "$address")" +} + +debit_account() { + local address="$1" + local amount="$2" + local idx status record balance new_balance last last_record + + idx="$(find_account_index "$address")" + status=$? + if [[ "$status" -ne 0 ]]; then + return 1 + fi + + record="$(read_record "$idx")" + balance="$(record_balance "$record")" + if (( amount <= 0 || balance < amount )); then + return 1 + fi + + new_balance=$((balance - amount)) + if (( new_balance > 0 )); then + write_record "$idx" "$(encode_record "$new_balance" "$address")" + return 0 + fi + + last="$(last_account_index)" + if (( last < 0 )); then + return 1 + fi + if [[ "$idx" != "$last" ]]; then + last_record="$(read_record "$last")" + write_record "$idx" "$last_record" + fi + zero_record "$last" +} + +valid_positive_i64_uint256() { + local amount_hex="$1" + [[ "${amount_hex:0:48}" == "000000000000000000000000000000000000000000000000" ]] || return 1 + [[ "${amount_hex:48:1}" =~ [0-7] ]] || return 1 +} + +handle_erc20_deposit() { + local msg_sender="$1" + local payload="$2" + local token sender amount_hex amount + + [[ -n "$trusted_portal" && -n "$trusted_token" ]] || return 1 + [[ "$msg_sender" == "$trusted_portal" ]] || return 1 + [[ ${#payload} -ge 144 ]] || return 1 + + token="${payload:0:40}" + sender="${payload:40:40}" + amount_hex="${payload:80:64}" + [[ "$token" == "$trusted_token" ]] || return 1 + valid_positive_i64_uint256 "$amount_hex" || return 1 + + amount="$(uint64_be_to_dec "${amount_hex:48:16}")" + (( amount > 0 )) || return 1 + credit_account "$sender" "$amount" +} + +emit_erc20_transfer_voucher() { + local recipient="$1" + local amount="$2" + local amount_be + + [[ -n "$trusted_token" ]] || return 1 + amount_be="$(printf '%064x' "$amount")" + printf '{"destination":"0x%s","value":"0x%s","payload":"0x%s000000000000000000000000%s%s"}\n' \ + "$trusted_token" "$ZERO_VALUE" "$ERC20_TRANSFER_SELECTOR" "$recipient" "$amount_be" | + rollup voucher >/dev/null +} + +handle_test_withdraw() { + local recipient="$1" + local payload="$2" + local amount_be amount + + [[ -n "$recipient" ]] || return 1 + [[ ${#payload} -eq 18 ]] || return 1 + [[ "${payload:0:2}" == "01" ]] || return 1 + amount_be="${payload:2:16}" + [[ "${amount_be:0:1}" =~ [0-7] ]] || return 1 + amount="$(uint64_be_to_dec "$amount_be")" + (( amount > 0 )) || return 1 + + debit_account "$recipient" "$amount" || return 1 + emit_erc20_transfer_voucher "$recipient" "$amount" +} + +inspect_balance() { + local query="$1" + local normalized address idx status record balance + + normalized="$(printf '%s' "$query" | tr 'A-F' 'a-f')" + if [[ "$normalized" =~ ^[[:space:]]*balance[[:space:]]+(0x)?([0-9a-f]{40})[[:space:]]*$ ]]; then + address="${BASH_REMATCH[2]}" + else + report_text '{"error":"usage: balance 0x
"}' + return 1 + fi + + idx="$(find_account_index "$address")" + status=$? + if [[ "$status" -eq 0 ]]; then + record="$(read_record "$idx")" + balance="$(record_balance "$record")" + report_text "$(printf '{"type":"erc20_balance","address":"0x%s","found":true,"account_index":"0x%x","balance":"%s"}' \ + "$address" "$idx" "$balance")" + return 0 + fi + if [[ "$status" -eq 1 ]]; then + report_text "$(printf '{"type":"erc20_balance","address":"0x%s","found":false,"balance":"0"}' "$address")" + return 0 + fi + + report_text '{"error":"account drive full"}' + return 1 +} + +request="$(accept_request)" +while true; do + printf '%s\n' "$request" >/tmp/request.json + request_type="$(jq -r .request_type /tmp/request.json)" + + if [[ "$request_type" == "inspect_state" ]]; then + payload="$(jq -r .data.payload /tmp/request.json | sed 's/^0x//')" + inspect_balance "$(hex_to_text "$payload")" + request="$(accept_request)" + continue + fi + + if [[ "$request_type" != "advance_state" ]]; then + request="$(accept_request)" + continue + fi + + msg_sender="$(jq -r .data.msg_sender /tmp/request.json | tr 'A-F' 'a-f' | sed 's/^0x//')" + payload="$(jq -r .data.payload /tmp/request.json | tr 'A-F' 'a-f' | sed 's/^0x//')" + + if handle_erc20_deposit "$msg_sender" "$payload"; then + report 6465706f736974206f6b + request="$(accept_request)" + elif handle_test_withdraw "$msg_sender" "$payload"; then + report 7769746864726177206f6b + request="$(accept_request)" + else + request="$(reject_request 62616420696e707574)" + fi +done diff --git a/apps/cli/tests/integration/commands/recovery.test.ts b/apps/cli/tests/integration/commands/recovery.test.ts new file mode 100644 index 00000000..9ff8cb63 --- /dev/null +++ b/apps/cli/tests/integration/commands/recovery.test.ts @@ -0,0 +1,338 @@ +import { createCartesiPublicClient } from "@cartesi/client"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { type ResultPromise, execa } from "execa"; +import fs from "fs-extra"; +import path from "node:path"; +import pRetry, { AbortError } from "p-retry"; +import tmp from "tmp"; +import { + type Address, + createTestClient, + http, + publicActions, + walletActions, +} from "viem"; +import { mnemonicToAccount } from "viem/accounts"; +import { DEVNET_MNEMONIC } from "../../../src/compose/node"; +import { + erc20PortalAbi, + erc20PortalAddress, + testUsdcAbi, + testUsdcAddress, + testUsdWithdrawalOutputBuilderAddress, +} from "../../../src/contracts"; +import { + getDeployments, + getProjectPort, + stopEnvironment, +} from "../../../src/exec/rollups"; +import { cartesi } from "../../../src/wallet"; +import { TEST_RUNTIME_VERSION, TEST_SDK } from "../config"; + +/** + * Covers the fund recovery of a foreclosed application end to end: a deposit + * that was not finalized is refunded, and the finalized balances, kept by the + * application in its accounts drive, are withdrawn from proofs generated by + * replaying the application. + * + * The runtime must ship a rollups node with the refund command, e.g. the + * images built from rollups-node#798 with CARTESI_TEST_SDK=cartesi/sdk:798 and + * CARTESI_TEST_RUNTIME_VERSION=798. + */ + +const EPOCH_LENGTH = 720; +const TIMEOUT = 30 * 60 * 1000; + +const cliPath = path.join(__dirname, "..", "..", "..", "dist", "index.js"); +const fixture = path.join(__dirname, "fixtures", "erc20-withdrawal"); +const projectName = `recovery-${process.pid}`; + +const account = (addressIndex: number) => + mnemonicToAccount(DEVNET_MNEMONIC, { addressIndex }).address; +const alice = account(0); +const guardian = account(1); +const bob = account(2); +const carol = account(3); + +const amounts = { alice: 100n, bob: 50n, carol: 25n }; + +/** + * Layout of the accounts drive in the built machine: its size in log2 of the + * number of 32-byte accounts, and its start as a multiple of its size + */ +const accountsDriveLayout = async (appDir: string, driveIndex: number) => { + const { config } = await fs.readJson( + path.join(appDir, ".cartesi", "image", "config.json"), + ); + const { start, length } = config.flash_drive[driveIndex]; + const log2Length = Math.log2(length); + expect(Number.isInteger(log2Length)).toBe(true); + expect(start % length).toBe(0); + return { + accountsDriveStartIndex: start / length, + log2MaxNumOfAccounts: log2Length - 5, + }; +}; + +describe("fund recovery", () => { + let appDir: string; + let cleanup: () => void; + let run: ResultPromise | undefined; + let application: Address; + let anvil: Awaited>; + let node: ReturnType; + + const cli = (args: string[]) => + execa("node", [cliPath, ...args, "--project-name", projectName], { + all: true, + cwd: appDir, + reject: false, + }); + + const createAnvilClient = async () => { + const host = await getProjectPort({ projectName }); + return createTestClient({ + chain: cartesi, + mode: "anvil", + transport: http(`http://${host}/anvil`), + pollingInterval: 200, + }) + .extend(publicActions) + .extend(walletActions); + }; + + const usdcBalance = (owner: Address) => + anvil.readContract({ + abi: testUsdcAbi, + address: testUsdcAddress, + args: [owner], + functionName: "balanceOf", + }); + + const deposit = async (from: Address, amount: bigint) => { + await anvil.impersonateAccount({ address: from }); + const transactions = [ + () => + anvil.writeContract({ + abi: testUsdcAbi, + account: from, + address: testUsdcAddress, + args: [amount], + functionName: "mint", + }), + () => + anvil.writeContract({ + abi: testUsdcAbi, + account: from, + address: testUsdcAddress, + args: [erc20PortalAddress, amount], + functionName: "approve", + }), + () => + anvil.writeContract({ + abi: erc20PortalAbi, + account: from, + address: erc20PortalAddress, + args: [testUsdcAddress, application, amount, "0x"], + functionName: "depositErc20Tokens", + }), + ]; + for (const transaction of transactions) { + const hash = await transaction(); + const receipt = await anvil.waitForTransactionReceipt({ hash }); + expect(receipt.status).toBe("success"); + } + }; + + const waitFor = (fn: () => Promise, retries = 120) => + pRetry(fn, { retries, minTimeout: 1000, maxTimeout: 1000 }); + + beforeAll(async () => { + const dir = tmp.dirSync({ unsafeCleanup: true }); + cleanup = dir.removeCallback; + appDir = path.join(dir.name, "erc20-withdrawal"); + await fs.copy(fixture, appDir); + await fs.writeFile( + path.join(appDir, "cartesi.toml"), + `sdk = "${TEST_SDK}" + +[machine.env] +TRUSTED_ERC20_PORTAL = "${erc20PortalAddress}" +TRUSTED_ERC20_TOKEN = "${testUsdcAddress}" + +[drives.accounts] +builder = "empty" +format = "raw" +size = "4MB" +mount = false +user = "dapp" +`, + ); + + console.log("! Building the erc20-withdrawal application..."); + const build = await execa("node", [cliPath, "build"], { + all: true, + cwd: appDir, + reject: false, + }); + expect(build.exitCode, build.all).toBe(0); + console.log("✓ Built the erc20-withdrawal application"); + + // the accounts drive follows the root drive + const layout = await accountsDriveLayout(appDir, 1); + await fs.appendFile( + path.join(appDir, "cartesi.toml"), + ` +[withdrawal.config] +guardian = "${guardian}" +log2_leaves_per_account = 0 +log2_max_num_of_accounts = ${layout.log2MaxNumOfAccounts} +accounts_drive_start_index = ${layout.accountsDriveStartIndex} +withdrawal_output_builder = "${testUsdWithdrawalOutputBuilderAddress}" +`, + ); + + // without a terminal the environment runs in the foreground + console.log(`! Starting the environment ${projectName}...`); + run = execa( + "node", + [ + cliPath, + "run", + "--project-name", + projectName, + "--runtime-version", + TEST_RUNTIME_VERSION, + "--epoch-length", + EPOCH_LENGTH.toString(), + "--block-time", + "1", + ], + { + all: true, + cwd: appDir, + reject: false, + }, + ); + + application = await waitFor(async () => { + if (run && run.nodeChildProcess.exitCode !== null) { + const { all } = await run; + throw new AbortError(`cartesi run exited\n${all}`); + } + const [deployment] = await getDeployments({ projectName }); + if (!deployment) { + throw new Error("application not deployed yet"); + } + return deployment.address; + }, 600); + console.log(`✓ Application deployed at ${application}`); + + anvil = await createAnvilClient(); + const host = await getProjectPort({ projectName }); + node = createCartesiPublicClient({ + transport: http(`http://${host}/rpc`), + }); + }, TIMEOUT); + + afterAll(async () => { + await stopEnvironment({ projectName }).catch(() => undefined); + run?.kill(); + cleanup?.(); + }, TIMEOUT); + + it( + "should finalize the first deposits", + async () => { + await deposit(alice, amounts.alice); + await deposit(bob, amounts.bob); + + // close the first epoch and wait for its claim to be accepted + await anvil.mine({ blocks: EPOCH_LENGTH }); + console.log("! Waiting for epoch 0 to be accepted..."); + const epochIndex = await waitFor(() => + node.getLastAcceptedEpochIndex({ application }), + ); + expect(epochIndex).toBe(0n); + console.log("✓ Epoch 0 accepted"); + expect(await usdcBalance(alice)).toBe(0n); + }, + TIMEOUT, + ); + + it( + "should not refund before the application is foreclosed", + async () => { + await deposit(carol, amounts.carol); + await waitFor(() => node.getInput({ application, inputIndex: 2n })); + + const refund = await cli(["refund", "2", "--yes"]); + expect(refund.exitCode).not.toBe(0); + expect(refund.all).toContain("NotForeclosed"); + }, + TIMEOUT, + ); + + it( + "should foreclose signing as the guardian", + async () => { + const foreclose = await cli(["foreclose", "--yes"]); + expect(foreclose.exitCode, foreclose.all).toBe(0); + }, + TIMEOUT, + ); + + it( + "should refund the deposit that was not finalized", + async () => { + expect(await usdcBalance(carol)).toBe(0n); + const refund = await cli(["refund", "2", "--yes", "--json"]); + expect(refund.exitCode, refund.all).toBe(0); + expect(JSON.parse(refund.stdout)).toMatchObject({ + input_index: "2", + }); + expect(await usdcBalance(carol)).toBe(amounts.carol); + }, + TIMEOUT, + ); + + it( + "should withdraw the finalized balance of an account", + async () => { + expect(await usdcBalance(alice)).toBe(0n); + const withdraw = await cli([ + "withdraw", + "--account", + alice, + "--yes", + "--json", + ]); + expect(withdraw.exitCode, withdraw.all).toBe(0); + expect(withdraw.stderr).toContain("Replayed"); + const result = JSON.parse(withdraw.stdout); + expect(result.application_address.toLowerCase()).toBe( + application.toLowerCase(), + ); + expect(await usdcBalance(alice)).toBe(amounts.alice); + }, + TIMEOUT, + ); + + it( + "should reuse the replayed machine for another account", + async () => { + expect(await usdcBalance(bob)).toBe(0n); + const withdraw = await cli([ + "withdraw", + "--account", + bob, + "--yes", + "--json", + ]); + expect(withdraw.exitCode, withdraw.all).toBe(0); + expect(withdraw.stderr).toContain("Using the machine replayed"); + expect(await usdcBalance(bob)).toBe(amounts.bob); + }, + TIMEOUT, + ); +}); From ffa1f30e914addd849ac9a007554b40b551c02d5 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 1 Oct 2026 22:05:39 +0100 Subject: [PATCH 07/25] chore(cli): add changeset for the fund recovery commands --- .changeset/common-insects-punch.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/common-insects-punch.md diff --git a/.changeset/common-insects-punch.md b/.changeset/common-insects-punch.md new file mode 100644 index 00000000..59842db0 --- /dev/null +++ b/.changeset/common-insects-punch.md @@ -0,0 +1,13 @@ +--- +"@cartesi/cli": minor +--- + +Add the `foreclose`, `refund` and `withdraw` commands to recover funds from a foreclosed application in the local environment. They run `cartesi-rollups-cli` and `cartesi-rollups-machine-tool` inside the rollups node container. + +- `cartesi foreclose` forecloses the application, signing as the guardian of its withdrawal config. By default it signs with the node's signer and finds the guardian among the first 20 accounts of its mnemonic, or uses `--account-index`. A guardian outside the node's signer is set for that one command with `CARTESI_AUTH_MNEMONIC` or `CARTESI_AUTH_PRIVATE_KEY` (and `CARTESI_AUTH_KIND` when both are set). +- `cartesi refund ` refunds a deposit that was not finalized before the foreclosure. The input bytes are read from the node, or from `--input-file`. +- `cartesi withdraw --account
` withdraws the finalized balance of an account. It replays the application up to its last finalized epoch, generates the accounts drive proofs, proves the accounts drive root once, and withdraws. `--proof-file` withdraws with a proof generated elsewhere, once the accounts drive root is proven. + +All three accept `--yes`, `--json`, `--no-wait` and `--wait-timeout`, which are forwarded to `cartesi-rollups-cli`. + +The replay re-runs every accepted input of the application, so it takes longer the more inputs a long-running application has processed. It stores a snapshot about the size of the machine RAM in the node container's `/tmp`, so it is bounded by the memory and disk available to the container (see `cartesi run --memory`). The snapshot is reused by further withdrawals of the same application, and is lost when the environment is recreated. From 2ace39b61eeac7eaa2c96854d80f36d032cf9daa Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 1 Oct 2026 22:05:39 +0100 Subject: [PATCH 08/25] docs: list foreclose, refund and withdraw commands in CLAUDE.md --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 986d1553..69c7b2db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ The CLI build pipeline (`apps/cli`): `clean` → `codegen` (wagmi ABI generation **Key subsystems**: -- **`commands/`** — Each file exports a `create*Command()` function returning a Commander command. Main commands: `build`, `run`, `deploy`, `send`, `deposit`, `create`, `doctor`, `shell`, `clean`, `hash`, `logs`, `status`, `address-book`. +- **`commands/`** — Each file exports a `create*Command()` function returning a Commander command. Main commands: `build`, `run`, `deploy`, `send`, `deposit`, `create`, `doctor`, `shell`, `clean`, `hash`, `logs`, `status`, `address-book`, `foreclose`, `refund`, `withdraw`. - **`builder/`** — Drive builder implementations (directory, docker, tar, empty, none). Each builder produces ext2 or SquashFS filesystems for Cartesi Machine drives. - **`compose/`** — Docker Compose service definitions generated as TypeScript objects (anvil, node, bundler, database, paymaster, proxy, explorer, etc.). - **`exec/`** — Wrappers around subprocess execution (cartesi-machine, rollups) using `execa`. From a41ff0ea36f6050fefa22cffd263460bb3a63e31 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Fri, 2 Oct 2026 18:05:27 +0100 Subject: [PATCH 09/25] test(cli): spy on createPublicClient instead of mocking viem --- apps/cli/tests/unit/validations.test.ts | 33 +++++++++++++++++-------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/apps/cli/tests/unit/validations.test.ts b/apps/cli/tests/unit/validations.test.ts index 651207aa..f75188e2 100644 --- a/apps/cli/tests/unit/validations.test.ts +++ b/apps/cli/tests/unit/validations.test.ts @@ -1,4 +1,13 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { + afterAll, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from "bun:test"; +import * as viem from "viem"; import { celo, sepolia } from "viem/chains"; import { daveAppFactoryAddress, @@ -18,18 +27,22 @@ const mockGetBlockNumber = mock().mockResolvedValue(12345n); const mockGetCode = mock().mockResolvedValue("0x1234"); const mockReadContract = mock().mockResolvedValue("some-result"); -mock.module("viem", () => { - return { - createPublicClient: () => ({ +// a spy instead of a module mock, as a module mock is not undone and would +// replace viem for the test files that run after this one +const createPublicClientSpy = spyOn( + viem, + "createPublicClient", +).mockImplementation( + () => + ({ getBlockNumber: mockGetBlockNumber, getCode: mockGetCode, readContract: mockReadContract, - }), - http: () => "mock-http-transport", - zeroAddress: "0x0000000000000000000000000000000000000000", - zeroHash: - "0x0000000000000000000000000000000000000000000000000000000000000000", - }; + }) as never, +); + +afterAll(() => { + createPublicClientSpy.mockRestore(); }); describe("validations", () => { From e07ad9f09bfa6f0f8a3a3beacda09ab92a679fa6 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 6 Oct 2026 12:23:14 +0100 Subject: [PATCH 10/25] feat(cli): let foreclose sign as a guardian outside the node signer --- apps/cli/src/commands/foreclose.ts | 203 +++++++++++++++--- apps/cli/src/exec/rollups.ts | 73 ++++++- apps/cli/src/wallet.ts | 10 +- .../cli/tests/unit/commands/foreclose.test.ts | 183 ++++++++++++++++ apps/cli/tests/unit/exec/rollups.test.ts | 113 ++++++++++ 5 files changed, 546 insertions(+), 36 deletions(-) create mode 100644 apps/cli/tests/unit/commands/foreclose.test.ts diff --git a/apps/cli/src/commands/foreclose.ts b/apps/cli/src/commands/foreclose.ts index b7fb291e..9cefc202 100644 --- a/apps/cli/src/commands/foreclose.ts +++ b/apps/cli/src/commands/foreclose.ts @@ -1,9 +1,22 @@ import { Command, Option } from "@commander-js/extra-typings"; import chalk from "chalk"; -import { isAddressEqual, zeroAddress } from "viem"; -import { getProjectName } from "../base.js"; import { + type Address, + type Hex, + isAddressEqual, + isHex, + zeroAddress, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { + type CartesiEnvironmentVariables, + getCartesiEnvironmentVariables, + getProjectName, +} from "../base.js"; +import { + getNodeSigner, handleNodeCommandError, + type NodeSigner, runNodeCommand, transactionArgs, } from "../exec/rollups.js"; @@ -12,13 +25,158 @@ import { parseAccountIndex, resolveNodeApplication, } from "../node.js"; -import { findMnemonicAccountIndex, getNodeMnemonic } from "../wallet.js"; +import { findMnemonicAccountIndex } from "../wallet.js"; + +/** + * Signer given for a single foreclose, in the environment of the shell that + * runs it, so the guardian can be an account the node doesn't sign with + */ +export type HostSigner = + | { kind: "mnemonic"; mnemonic: string } + | { kind: "private_key"; privateKey: Hex }; + +/** + * Read the per-command signer from the CARTESI_AUTH_* environment variables + * of the host, following cartesi-rollups-cli: CARTESI_AUTH_KIND picks the + * kind, otherwise the variable that is set does + * @returns the signer, or undefined when none is set + */ +export const getHostSigner = ( + env: CartesiEnvironmentVariables = getCartesiEnvironmentVariables(), +): HostSigner | undefined => { + const { + CARTESI_AUTH_KIND: kind, + CARTESI_AUTH_MNEMONIC: mnemonic, + CARTESI_AUTH_PRIVATE_KEY: privateKey, + } = env; + + if (!kind && !mnemonic && !privateKey) { + return undefined; + } + if (!kind && mnemonic && privateKey) { + throw new Error( + "Both CARTESI_AUTH_MNEMONIC and CARTESI_AUTH_PRIVATE_KEY are set, set CARTESI_AUTH_KIND to mnemonic or private_key to choose", + ); + } + + const resolvedKind = kind ?? (mnemonic ? "mnemonic" : "private_key"); + switch (resolvedKind) { + case "mnemonic": + if (!mnemonic) { + throw new Error( + "CARTESI_AUTH_KIND is mnemonic, but CARTESI_AUTH_MNEMONIC is not set", + ); + } + return { kind: "mnemonic", mnemonic }; + case "private_key": + if (!privateKey || !isHex(privateKey)) { + throw new Error( + "CARTESI_AUTH_KIND is private_key, but CARTESI_AUTH_PRIVATE_KEY is not a 0x-prefixed private key", + ); + } + return { kind: "private_key", privateKey }; + default: + throw new Error( + `CARTESI_AUTH_KIND ${resolvedKind} isn't supported to foreclose, use mnemonic or private_key`, + ); + } +}; + +/** + * Work out how to sign as the guardian, the only account allowed to + * foreclose. A signer given for the command takes precedence over the node's. + * cartesi-rollups-cli signs with whatever its environment configures, so for + * a mnemonic the guardian's account index is looked up, unless one is given. + * A private key has no index to choose. + * @returns the account index and environment to sign with, and a warning to show, if any + */ +export const resolveForecloseSigner = (options: { + accountIndex?: number; + guardian: Address; + host?: HostSigner; + node?: NodeSigner; +}): { + accountIndex?: number; + env?: Record; + warning?: string; +} => { + const { accountIndex, guardian, host, node } = options; + + if (isAddressEqual(guardian, zeroAddress)) { + throw new Error( + "The application was deployed without a withdrawal config and cannot be foreclosed", + ); + } + + const ignoredIndex = + accountIndex === undefined + ? {} + : { + warning: + "--account-index is ignored when signing with a private key", + }; + + if (host?.kind === "private_key") { + const { address } = privateKeyToAccount(host.privateKey); + if (!isAddressEqual(address, guardian)) { + throw new Error( + `The private key's address ${address} isn't the guardian ${guardian}`, + ); + } + return { + ...ignoredIndex, + env: { + CARTESI_AUTH_KIND: "private_key", + CARTESI_AUTH_PRIVATE_KEY: host.privateKey, + }, + }; + } + + // the node's private key is never read, so it can't be checked here + const signer = host ?? node; + if (!signer) { + throw new Error("No signer to foreclose with"); + } + if (signer.kind === "private_key") { + return ignoredIndex; + } + + const env = host + ? { + CARTESI_AUTH_KIND: "mnemonic", + CARTESI_AUTH_MNEMONIC: host.mnemonic, + } + : undefined; + if (accountIndex !== undefined) { + return { accountIndex, env }; + } + + const index = findMnemonicAccountIndex({ + address: guardian, + mnemonic: signer.mnemonic, + }); + if (index === undefined) { + throw new Error( + `Guardian ${guardian} isn't one of the first 20 accounts of the mnemonic in use. If it belongs to another mnemonic, set CARTESI_AUTH_MNEMONIC; if its index is higher, pass --account-index; if it's a raw private key, set CARTESI_AUTH_PRIVATE_KEY`, + ); + } + return { accountIndex: index, env }; +}; export const createForecloseCommand = () => { return new Command("foreclose") .description( "Forecloses the application, so users can withdraw their funds", ) + .addHelpText( + "after", + ` +Signs as the application guardian. By default it uses the rollups node signer, looking up the guardian in its mnemonic. +For a guardian outside it, set the signer for this command only: + CARTESI_AUTH_MNEMONIC="" cartesi foreclose + CARTESI_AUTH_PRIVATE_KEY=<0x-private-key> cartesi foreclose +Set CARTESI_AUTH_KIND to mnemonic or private_key when both are set.`, + ) .configureHelp({ showGlobalOptions: true }) .option("--application
", "application address") .option( @@ -28,7 +186,7 @@ export const createForecloseCommand = () => { .addOption( new Option( "--account-index ", - "index of the guardian account in the node mnemonic (default: derived from the application guardian)", + "index of the guardian in the mnemonic (default: looked up from the application guardian; ignored for private-key signers)", ).argParser(parseAccountIndex), ) .option("-y, --yes", "skip the confirmation prompt") @@ -48,28 +206,20 @@ export const createForecloseCommand = () => { projectName, }); - // the guardian is the only account allowed to foreclose - let accountIndex = options.accountIndex; - if (accountIndex === undefined) { - const client = await getNodeClient({ projectName }); - const { withdrawalConfig } = await client.getApplication({ - application, - }); - const { guardian } = withdrawalConfig; - if (isAddressEqual(guardian, zeroAddress)) { - throw new Error( - `Application ${chalk.cyan(application)} was deployed without a withdrawal config and cannot be foreclosed`, - ); - } - accountIndex = findMnemonicAccountIndex({ - address: guardian, - mnemonic: getNodeMnemonic(), - }); - if (accountIndex === undefined) { - throw new Error( - `Guardian ${chalk.cyan(guardian)} is not derived from the node mnemonic, use ${chalk.cyan("--account-index")}`, - ); - } + const client = await getNodeClient({ projectName }); + const { withdrawalConfig } = await client.getApplication({ + application, + }); + // the node signer is only needed without a signer for the command + const host = getHostSigner(); + const { accountIndex, env, warning } = resolveForecloseSigner({ + accountIndex: options.accountIndex, + guardian: withdrawalConfig.guardian, + host, + node: host ? undefined : await getNodeSigner({ projectName }), + }); + if (warning) { + console.warn(chalk.yellow(warning)); } try { @@ -81,6 +231,7 @@ export const createForecloseCommand = () => { application, ...transactionArgs(options), ], + env, projectName, }); } catch (error: unknown) { diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index 816ec7a0..284a6679 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -759,16 +759,22 @@ export const transactionArgs = (options: TransactionOptions): string[] => { export const nodeExecArgs = (options: { accountIndex?: number; command: string[]; + env?: Record; interactive: boolean; projectName: string; workdir?: string; }): string[] => { - const { accountIndex, command, interactive, projectName, workdir } = + const { accountIndex, command, env, interactive, projectName, workdir } = options; const args = ["compose", "--project-name", projectName, "exec"]; if (!interactive) { args.push("-T"); } + // only the names: docker takes the values from its own environment, so + // secrets never show up in the process arguments + for (const name of Object.keys(env ?? {})) { + args.push("-e", name); + } if (accountIndex !== undefined) { args.push("-e", `CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=${accountIndex}`); } @@ -786,6 +792,7 @@ export const nodeExecArgs = (options: { export const runNodeCommand = (options: { accountIndex?: number; command: string[]; + env?: Record; projectName: string; stdout?: "stdout" | "stderr"; }) => @@ -793,6 +800,7 @@ export const runNodeCommand = (options: { "docker", nodeExecArgs({ ...options, interactive: !!process.stdin.isTTY }), { + env: options.env, stdin: "inherit", stdout: options.stdout === "stderr" ? 2 : "inherit", stderr: "inherit", @@ -888,6 +896,69 @@ export const handleNodeCommandError = (error: unknown) => { throw error; }; +/** + * Signer the rollups node container is configured with. Private keys are never + * read out of the container, only their kind. + */ +export type NodeSigner = + | { kind: "mnemonic"; mnemonic: string } + | { kind: "private_key" }; + +/** + * Print the signer kind and, for mnemonic signers, the mnemonic itself, the + * same way cartesi-rollups-cli resolves them: an inline mnemonic takes + * precedence over the one in CARTESI_AUTH_MNEMONIC_FILE. + */ +export const NODE_SIGNER_SCRIPT = `kind="\${CARTESI_AUTH_KIND:-mnemonic}" +printf '%s\\n' "$kind" +case "$kind" in +mnemonic|mnemonic_file) + if [ -n "$CARTESI_AUTH_MNEMONIC" ]; then + printf '%s\\n' "$CARTESI_AUTH_MNEMONIC" + elif [ -n "$CARTESI_AUTH_MNEMONIC_FILE" ]; then + cat "$CARTESI_AUTH_MNEMONIC_FILE" + fi + ;; +esac`; + +/** + * Parse the output of the node signer script + */ +export const parseNodeSigner = (stdout: string): NodeSigner => { + const [kind = "", ...rest] = stdout.trim().split("\n"); + switch (kind.trim()) { + case "mnemonic": + case "mnemonic_file": { + const mnemonic = rest.join(" ").trim(); + if (!mnemonic) { + throw new Error("The rollups node has no mnemonic configured"); + } + return { kind: "mnemonic", mnemonic }; + } + case "private_key": + case "private_key_file": + return { kind: "private_key" }; + default: + throw new Error( + `Signer kind ${kind.trim()} of the rollups node isn't supported by the CLI`, + ); + } +}; + +/** + * Read the signer the rollups node container is configured with, which is + * the one cartesi-rollups-cli signs with unless a command overrides it + */ +export const getNodeSigner = async (options: { + projectName: string; +}): Promise => { + const stdout = await execNodeCommand({ + command: ["sh", "-c", NODE_SIGNER_SCRIPT], + projectName: options.projectName, + }); + return parseNodeSigner(stdout); +}; + /** * Layout of the accounts drive, as configured in the application contract */ diff --git a/apps/cli/src/wallet.ts b/apps/cli/src/wallet.ts index 52a8a73d..595df6b4 100644 --- a/apps/cli/src/wallet.ts +++ b/apps/cli/src/wallet.ts @@ -10,8 +10,7 @@ import { } from "viem"; import { mnemonicToAccount } from "viem/accounts"; import { anvil } from "viem/chains"; -import { getCartesiEnvironmentVariables, getProjectName } from "./base.js"; -import { DEVNET_MNEMONIC } from "./compose/node.js"; +import { getProjectName } from "./base.js"; import { PREFERRED_PORT } from "./config.js"; import { getProjectPort } from "./exec/rollups.js"; @@ -60,13 +59,6 @@ export const connect = async (options: { return client; }; -/** - * Mnemonic the rollups node signs with: the one passed down from the host - * environment, or the devnet one - */ -export const getNodeMnemonic = (): string => - getCartesiEnvironmentVariables().CARTESI_AUTH_MNEMONIC ?? DEVNET_MNEMONIC; - /** * Find the index of the account derived from a mnemonic that has the given * address diff --git a/apps/cli/tests/unit/commands/foreclose.test.ts b/apps/cli/tests/unit/commands/foreclose.test.ts new file mode 100644 index 00000000..b7b387fe --- /dev/null +++ b/apps/cli/tests/unit/commands/foreclose.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "bun:test"; +import { zeroAddress } from "viem"; +import { mnemonicToAccount } from "viem/accounts"; +import { + getHostSigner, + resolveForecloseSigner, +} from "../../../src/commands/foreclose"; +import { DEVNET_MNEMONIC } from "../../../src/compose/node"; + +// account 1 of the devnet mnemonic +const guardian = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; +// its private key, a well known anvil account +const guardianKey = + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; +const node = { kind: "mnemonic", mnemonic: DEVNET_MNEMONIC } as const; +const nodePrivateKey = { kind: "private_key" } as const; +const otherMnemonic = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; +const otherGuardian = mnemonicToAccount(otherMnemonic, { + addressIndex: 2, +}).address; + +describe("getHostSigner", () => { + it("should return undefined when nothing is set", () => { + expect(getHostSigner({})).toBeUndefined(); + }); + + it("should infer the kind from the variable that is set", () => { + expect(getHostSigner({ CARTESI_AUTH_MNEMONIC: "a b c" })).toEqual({ + kind: "mnemonic", + mnemonic: "a b c", + }); + expect( + getHostSigner({ CARTESI_AUTH_PRIVATE_KEY: guardianKey }), + ).toEqual({ kind: "private_key", privateKey: guardianKey }); + }); + + it("should let CARTESI_AUTH_KIND choose when both are set", () => { + const env = { + CARTESI_AUTH_MNEMONIC: "a b c", + CARTESI_AUTH_PRIVATE_KEY: guardianKey, + }; + expect(() => getHostSigner(env)).toThrow("CARTESI_AUTH_KIND"); + expect( + getHostSigner({ ...env, CARTESI_AUTH_KIND: "private_key" }), + ).toEqual({ kind: "private_key", privateKey: guardianKey }); + expect( + getHostSigner({ ...env, CARTESI_AUTH_KIND: "mnemonic" }), + ).toEqual({ kind: "mnemonic", mnemonic: "a b c" }); + }); + + it("should reject a kind without its variable", () => { + expect(() => getHostSigner({ CARTESI_AUTH_KIND: "mnemonic" })).toThrow( + "CARTESI_AUTH_MNEMONIC", + ); + expect(() => + getHostSigner({ CARTESI_AUTH_KIND: "private_key" }), + ).toThrow("CARTESI_AUTH_PRIVATE_KEY"); + }); + + it("should reject a private key that isn't hex", () => { + expect(() => + getHostSigner({ CARTESI_AUTH_PRIVATE_KEY: "not-a-key" }), + ).toThrow("0x-prefixed"); + }); + + it("should reject kinds not supported per command", () => { + expect(() => + getHostSigner({ CARTESI_AUTH_KIND: "mnemonic_file" }), + ).toThrow("mnemonic_file"); + expect(() => getHostSigner({ CARTESI_AUTH_KIND: "aws" })).toThrow( + "aws", + ); + }); +}); + +describe("resolveForecloseSigner", () => { + it("should reject an application without a withdrawal config", () => { + expect(() => + resolveForecloseSigner({ guardian: zeroAddress, node }), + ).toThrow("without a withdrawal config"); + }); + + describe("with the node signer", () => { + it("should look up the guardian in the node mnemonic", () => { + expect(resolveForecloseSigner({ guardian, node })).toEqual({ + accountIndex: 1, + env: undefined, + }); + }); + + it("should use the given account index without a lookup", () => { + expect( + resolveForecloseSigner({ accountIndex: 25, guardian, node }), + ).toEqual({ accountIndex: 25, env: undefined }); + }); + + it("should explain how to sign as a guardian it can't find", () => { + expect(() => + resolveForecloseSigner({ guardian: otherGuardian, node }), + ).toThrow( + /CARTESI_AUTH_MNEMONIC.*--account-index.*CARTESI_AUTH_PRIVATE_KEY/, + ); + }); + + it("should sign with a private key as configured", () => { + expect( + resolveForecloseSigner({ guardian, node: nodePrivateKey }), + ).toEqual({}); + }); + + it("should warn that the index is ignored for a private key", () => { + const result = resolveForecloseSigner({ + accountIndex: 3, + guardian, + node: nodePrivateKey, + }); + expect(result.accountIndex).toBeUndefined(); + expect(result.warning).toContain("ignored"); + }); + }); + + describe("with a signer for the command", () => { + it("should look up the guardian in that mnemonic", () => { + expect( + resolveForecloseSigner({ + guardian: otherGuardian, + host: { kind: "mnemonic", mnemonic: otherMnemonic }, + node, + }), + ).toEqual({ + accountIndex: 2, + env: { + CARTESI_AUTH_KIND: "mnemonic", + CARTESI_AUTH_MNEMONIC: otherMnemonic, + }, + }); + }); + + it("should take precedence over the node signer", () => { + expect(() => + resolveForecloseSigner({ + guardian, + host: { kind: "mnemonic", mnemonic: otherMnemonic }, + node, + }), + ).toThrow("isn't one of the first 20 accounts"); + }); + + it("should sign with the guardian's private key", () => { + expect( + resolveForecloseSigner({ + guardian, + host: { kind: "private_key", privateKey: guardianKey }, + }), + ).toEqual({ + env: { + CARTESI_AUTH_KIND: "private_key", + CARTESI_AUTH_PRIVATE_KEY: guardianKey, + }, + }); + }); + + it("should reject a private key that isn't the guardian", () => { + expect(() => + resolveForecloseSigner({ + guardian: otherGuardian, + host: { kind: "private_key", privateKey: guardianKey }, + }), + ).toThrow("isn't the guardian"); + }); + + it("should warn that the index is ignored for a private key", () => { + const result = resolveForecloseSigner({ + accountIndex: 3, + guardian, + host: { kind: "private_key", privateKey: guardianKey }, + }); + expect(result.accountIndex).toBeUndefined(); + expect(result.warning).toContain("ignored"); + }); + }); +}); diff --git a/apps/cli/tests/unit/exec/rollups.test.ts b/apps/cli/tests/unit/exec/rollups.test.ts index 050f65f9..82f968a8 100644 --- a/apps/cli/tests/unit/exec/rollups.test.ts +++ b/apps/cli/tests/unit/exec/rollups.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from "bun:test"; +import { execa } from "execa"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { toHex } from "viem"; import { buildDeployApplicationArgs, + NODE_SIGNER_SCRIPT, NODE_TEMPLATE_PATH, nodeExecArgs, parseApplications, + parseNodeSigner, parseProveSummary, parseReplaySummary, proveAccountsDriveArgs, @@ -201,6 +207,32 @@ describe("transactionArgs", () => { }); describe("nodeExecArgs", () => { + it("should forward environment variables by name only", () => { + const args = nodeExecArgs({ + command: ["true"], + env: { + CARTESI_AUTH_KIND: "mnemonic", + CARTESI_AUTH_MNEMONIC: "a b c", + }, + interactive: false, + projectName: "dapp", + }); + expect(args).toEqual([ + "compose", + "--project-name", + "dapp", + "exec", + "-T", + "-e", + "CARTESI_AUTH_KIND", + "-e", + "CARTESI_AUTH_MNEMONIC", + "rollups_node", + "true", + ]); + expect(args.join(" ")).not.toContain("a b c"); + }); + it("should run the command in the working directory", () => { expect( nodeExecArgs({ @@ -409,3 +441,84 @@ describe("parseProveSummary", () => { ).toThrow("withdraw_proof_file"); }); }); + +describe("parseNodeSigner", () => { + it("should parse a mnemonic signer", () => { + expect(parseNodeSigner("mnemonic\ntest test junk\n")).toEqual({ + kind: "mnemonic", + mnemonic: "test test junk", + }); + }); + + it("should parse a mnemonic file signer as a mnemonic", () => { + expect(parseNodeSigner("mnemonic_file\ntest test junk")).toEqual({ + kind: "mnemonic", + mnemonic: "test test junk", + }); + }); + + it("should parse private key signers without their key", () => { + expect(parseNodeSigner("private_key\n")).toEqual({ + kind: "private_key", + }); + expect(parseNodeSigner("private_key_file\n")).toEqual({ + kind: "private_key", + }); + }); + + it("should reject a mnemonic signer without a mnemonic", () => { + expect(() => parseNodeSigner("mnemonic\n")).toThrow("no mnemonic"); + }); + + it("should reject unsupported signer kinds", () => { + expect(() => parseNodeSigner("aws\n")).toThrow("aws"); + expect(() => parseNodeSigner("ledger\n")).toThrow("ledger"); + }); +}); + +describe("NODE_SIGNER_SCRIPT", () => { + const run = async (env: Record) => + parseNodeSigner( + ( + await execa("sh", ["-c", NODE_SIGNER_SCRIPT], { + env, + extendEnv: false, + }) + ).stdout, + ); + + it("should default to an inline mnemonic", async () => { + expect(await run({ CARTESI_AUTH_MNEMONIC: "a b c" })).toEqual({ + kind: "mnemonic", + mnemonic: "a b c", + }); + }); + + it("should read the mnemonic file", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "signer-")); + const file = path.join(dir, "mnemonic"); + fs.writeFileSync(file, "x y z\n"); + try { + expect( + await run({ + CARTESI_AUTH_KIND: "mnemonic_file", + CARTESI_AUTH_MNEMONIC_FILE: file, + }), + ).toEqual({ kind: "mnemonic", mnemonic: "x y z" }); + } finally { + fs.rmSync(dir, { recursive: true }); + } + }); + + it("should never print a private key", async () => { + const { stdout } = await execa("sh", ["-c", NODE_SIGNER_SCRIPT], { + env: { + CARTESI_AUTH_KIND: "private_key", + CARTESI_AUTH_PRIVATE_KEY: "0xsecret", + }, + extendEnv: false, + }); + expect(stdout).not.toContain("0xsecret"); + expect(parseNodeSigner(stdout)).toEqual({ kind: "private_key" }); + }); +}); From 8a55e8a01c26dcfd0c24a32650d77db6beba6b3a Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 6 Oct 2026 12:47:39 +0100 Subject: [PATCH 11/25] fix(cli): replay and cache only the epoch the chain finalized --- apps/cli/src/commands/withdraw.ts | 106 ++++++++++++++++++++++++------ 1 file changed, 87 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/commands/withdraw.ts b/apps/cli/src/commands/withdraw.ts index 54c08a26..e544a418 100644 --- a/apps/cli/src/commands/withdraw.ts +++ b/apps/cli/src/commands/withdraw.ts @@ -3,9 +3,13 @@ import { Command, Option } from "@commander-js/extra-typings"; import chalk from "chalk"; import fs from "fs-extra"; import ora from "ora"; -import type { Address, Hash } from "viem"; +import pRetry from "p-retry"; +import { type Address, type Hash, zeroHash } from "viem"; import { getProjectName, parseAddress } from "../base.js"; -import { iApplicationAbi } from "../contracts.js"; +import { + iApplicationAbi, + iOutputsMerkleRootValidatorAbi, +} from "../contracts.js"; import { execNodeCommand, handleNodeCommandError, @@ -35,7 +39,8 @@ const WORK_DIR = "/tmp/cartesi-withdraw"; /** * Read the foreclosure state of the application from the chain. The node * observes it some blocks later, which is too late for a withdrawal that - * follows another one. + * follows another one. The last finalized machine root is the one the proofs + * are checked against on chain. */ const readForeclosureState = async (options: { application: Address; @@ -43,7 +48,7 @@ const readForeclosureState = async (options: { }) => { const { application, projectName } = options; const client = await connect({ projectName }); - const [foreclosed, [driveRootProven]] = await Promise.all([ + const [foreclosed, [driveRootProven], validator] = await Promise.all([ client.readContract({ abi: iApplicationAbi, address: application, @@ -54,12 +59,65 @@ const readForeclosureState = async (options: { address: application, functionName: "getAccountsDriveMerkleRoot", }), + client.readContract({ + abi: iApplicationAbi, + address: application, + functionName: "getOutputsMerkleRootValidator", + }), ]); - return { driveRootProven, foreclosed }; + const finalizedMachineRoot = await client.readContract({ + abi: iOutputsMerkleRootValidatorAbi, + address: validator, + args: [application], + functionName: "getLastFinalizedMachineMerkleRoot", + }); + return { driveRootProven, finalizedMachineRoot, foreclosed }; }; const isSameHash = (a: Hash, b: Hash) => a.toLowerCase() === b.toLowerCase(); +/** + * Find the epoch the node has with the machine root finalized on chain. The + * node records accepted epochs some blocks after the chain, so it may still + * report an older one; nothing is finalized after the foreclosure, so it + * catches up. + * @returns the index of the epoch + */ +const waitForFinalizedEpoch = async (options: { + application: Address; + client: CartesiPublicClient; + finalizedMachineRoot: Hash; +}): Promise => { + const { application, client, finalizedMachineRoot } = options; + let last: { epochIndex?: bigint; machineHash?: Hash | null } = {}; + try { + return await pRetry( + async () => { + const epochIndex = await client.getLastAcceptedEpochIndex({ + application, + }); + const { machineHash } = await client.getEpoch({ + application, + epochIndex, + }); + last = { epochIndex, machineHash }; + if ( + !machineHash || + !isSameHash(machineHash, finalizedMachineRoot) + ) { + throw new Error("node is behind the chain"); + } + return epochIndex; + }, + { retries: 15, minTimeout: 1_000, maxTimeout: 2_000, factor: 1.2 }, + ); + } catch { + throw new Error( + `The node hasn't processed the last finalized epoch yet (node epoch ${last.epochIndex ?? "none"} root ${last.machineHash ?? "none"}, chain root ${finalizedMachineRoot}); try again shortly`, + ); + } +}; + /** * Replay the application up to its last finalized epoch, unless a previous * withdrawal already did it. Nothing is finalized after the foreclosure, so @@ -69,22 +127,27 @@ const isSameHash = (a: Hash, b: Hash) => a.toLowerCase() === b.toLowerCase(); const replayLastFinalizedEpoch = async (options: { application: Address; client: CartesiPublicClient; + finalizedMachineRoot: Hash; projectName: string; }): Promise<{ machineHash: Hash; store: string }> => { - const { application, client, projectName } = options; - const progress = ora("Looking up the last finalized epoch...").start(); + const { application, client, finalizedMachineRoot, projectName } = options; + const machineHash = finalizedMachineRoot; + + if (isSameHash(finalizedMachineRoot, zeroHash)) { + throw new Error("No finalized epoch, there are no funds to withdraw"); + } + const progress = ora("Looking up the last finalized epoch...").start(); let epochIndex: bigint; try { - epochIndex = await client.getLastAcceptedEpochIndex({ application }); + epochIndex = await waitForFinalizedEpoch({ + application, + client, + finalizedMachineRoot, + }); } catch (error: unknown) { - progress.fail("No finalized epoch, there are no funds to withdraw"); - throw error; - } - const { machineHash } = await client.getEpoch({ application, epochIndex }); - if (!machineHash) { progress.fail(); - throw new Error(`Epoch ${epochIndex} has no machine hash`); + throw error; } const store = `${WORK_DIR}/${application}/epoch-${epochIndex}`; @@ -134,6 +197,7 @@ const proveAccount = async (options: { application: Address; client: CartesiPublicClient; driveRootProven: boolean; + finalizedMachineRoot: Hash; projectName: string; tx: TransactionOptions; }): Promise => { @@ -143,6 +207,7 @@ const proveAccount = async (options: { application, client, driveRootProven, + finalizedMachineRoot, projectName, tx, } = options; @@ -153,6 +218,7 @@ const proveAccount = async (options: { const { machineHash, store } = await replayLastFinalizedEpoch({ application, client, + finalizedMachineRoot, projectName, }); @@ -171,8 +237,11 @@ const proveAccount = async (options: { }); if (!isSameHash(proof.machineRoot, machineHash)) { progress.fail(); + // a cached snapshot that doesn't prove the finalized root would fail + // every retry, so drop it and let the next run replay + await removeNodePath({ path: store, projectName }); throw new Error( - `Proof machine root ${proof.machineRoot} does not match the finalized machine hash ${machineHash}`, + `Proof machine root ${proof.machineRoot} does not match the finalized machine root ${machineHash}. Removed the cached snapshot; run the command again to replay it`, ); } progress.succeed( @@ -248,10 +317,8 @@ export const createWithdrawCommand = () => { }); const client = await getNodeClient({ projectName }); - const { driveRootProven, foreclosed } = await readForeclosureState({ - application, - projectName, - }); + const { driveRootProven, finalizedMachineRoot, foreclosed } = + await readForeclosureState({ application, projectName }); if (!foreclosed) { throw new Error( `Application ${chalk.cyan(application)} is not foreclosed, use ${chalk.cyan("cartesi foreclose")} first`, @@ -284,6 +351,7 @@ export const createWithdrawCommand = () => { application, client, driveRootProven, + finalizedMachineRoot, projectName, tx: options, }); From fa7d0d41bd7402d331c255cbbd588dcb7b1b40e2 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 6 Oct 2026 13:19:05 +0100 Subject: [PATCH 12/25] test(cli): cover the refund, withdraw and application resolution logic --- apps/cli/src/commands/refund.ts | 8 +- apps/cli/src/commands/withdraw.ts | 188 ++++++++++----- apps/cli/src/node.ts | 14 +- apps/cli/tests/unit/commands/refund.test.ts | 100 ++++++++ apps/cli/tests/unit/commands/withdraw.test.ts | 216 ++++++++++++++++++ apps/cli/tests/unit/node.test.ts | 57 ++++- 6 files changed, 523 insertions(+), 60 deletions(-) create mode 100644 apps/cli/tests/unit/commands/refund.test.ts create mode 100644 apps/cli/tests/unit/commands/withdraw.test.ts diff --git a/apps/cli/src/commands/refund.ts b/apps/cli/src/commands/refund.ts index fbe18623..72c26d25 100644 --- a/apps/cli/src/commands/refund.ts +++ b/apps/cli/src/commands/refund.ts @@ -1,3 +1,4 @@ +import type { CartesiPublicClient } from "@cartesi/client"; import { Command, InvalidArgumentError, @@ -23,7 +24,7 @@ import { /** * Commander parser of an input index, in decimal or 0x-prefixed hexadecimal */ -const parseInputIndex = (value: string): bigint => { +export const parseInputIndex = (value: string): bigint => { if (!/^(0x[0-9a-fA-F]+|[0-9]+)$/.test(value)) { throw new InvalidArgumentError("Not a valid input index."); } @@ -34,8 +35,9 @@ const parseInputIndex = (value: string): bigint => { * Get the complete InputAdded.input bytes of the deposit to refund, from a * file or from the node */ -const getInputData = async (options: { +export const getInputData = async (options: { application: Address; + client?: Pick; inputFile?: string; inputIndex: bigint; projectName: string; @@ -52,7 +54,7 @@ const getInputData = async (options: { return data; } - const client = await getNodeClient({ projectName }); + const client = options.client ?? (await getNodeClient({ projectName })); try { const input = await client.getInput({ application, inputIndex }); return input.rawData; diff --git a/apps/cli/src/commands/withdraw.ts b/apps/cli/src/commands/withdraw.ts index e544a418..5dab7b62 100644 --- a/apps/cli/src/commands/withdraw.ts +++ b/apps/cli/src/commands/withdraw.ts @@ -3,7 +3,7 @@ import { Command, Option } from "@commander-js/extra-typings"; import chalk from "chalk"; import fs from "fs-extra"; import ora from "ora"; -import pRetry from "p-retry"; +import pRetry, { type Options as RetryOptions } from "p-retry"; import { type Address, type Hash, zeroHash } from "viem"; import { getProjectName, parseAddress } from "../base.js"; import { @@ -11,6 +11,7 @@ import { iOutputsMerkleRootValidatorAbi, } from "../contracts.js"; import { + type AccountsDriveConfig, execNodeCommand, handleNodeCommandError, nodePathExists, @@ -36,6 +37,43 @@ import { connect } from "../wallet.js"; */ const WORK_DIR = "/tmp/cartesi-withdraw"; +/** + * Path of the machine replayed up to an epoch. Keyed by application and epoch, + * so a redeploy or another epoch never reuses a snapshot. + */ +export const replayStorePath = (application: Address, epochIndex: bigint) => + `${WORK_DIR}/${application}/epoch-${epochIndex}`; + +/** + * Operations on the rollups node container used to replay and prove, so tests + * can run the steps without one + */ +export type WithdrawIo = { + execNodeCommand: typeof execNodeCommand; + nodePathExists: typeof nodePathExists; + proveAccountsDrive: typeof proveAccountsDrive; + removeNodePath: typeof removeNodePath; + replayMachine: typeof replayMachine; +}; + +const defaultIo: WithdrawIo = { + execNodeCommand, + nodePathExists, + proveAccountsDrive, + removeNodePath, + replayMachine, +}; + +/** + * How long to wait for the node to process the last finalized epoch + */ +const FINALIZED_EPOCH_RETRY: RetryOptions = { + retries: 15, + minTimeout: 1_000, + maxTimeout: 2_000, + factor: 1.2, +}; + /** * Read the foreclosure state of the application from the chain. The node * observes it some blocks later, which is too late for a withdrawal that @@ -85,32 +123,30 @@ const isSameHash = (a: Hash, b: Hash) => a.toLowerCase() === b.toLowerCase(); */ const waitForFinalizedEpoch = async (options: { application: Address; - client: CartesiPublicClient; + client: Pick; finalizedMachineRoot: Hash; + retry: RetryOptions; }): Promise => { - const { application, client, finalizedMachineRoot } = options; + const { application, client, finalizedMachineRoot, retry } = options; let last: { epochIndex?: bigint; machineHash?: Hash | null } = {}; try { - return await pRetry( - async () => { - const epochIndex = await client.getLastAcceptedEpochIndex({ - application, - }); - const { machineHash } = await client.getEpoch({ - application, - epochIndex, - }); - last = { epochIndex, machineHash }; - if ( - !machineHash || - !isSameHash(machineHash, finalizedMachineRoot) - ) { - throw new Error("node is behind the chain"); - } - return epochIndex; - }, - { retries: 15, minTimeout: 1_000, maxTimeout: 2_000, factor: 1.2 }, - ); + return await pRetry(async () => { + const epochIndex = await client.getLastAcceptedEpochIndex({ + application, + }); + const { machineHash } = await client.getEpoch({ + application, + epochIndex, + }); + last = { epochIndex, machineHash }; + if ( + !machineHash || + !isSameHash(machineHash, finalizedMachineRoot) + ) { + throw new Error("node is behind the chain"); + } + return epochIndex; + }, retry); } catch { throw new Error( `The node hasn't processed the last finalized epoch yet (node epoch ${last.epochIndex ?? "none"} root ${last.machineHash ?? "none"}, chain root ${finalizedMachineRoot}); try again shortly`, @@ -124,13 +160,22 @@ const waitForFinalizedEpoch = async (options: { * the replayed machine never changes. * @returns path of the replayed machine inside the rollups node container */ -const replayLastFinalizedEpoch = async (options: { +export const replayLastFinalizedEpoch = async (options: { application: Address; - client: CartesiPublicClient; + client: Pick; finalizedMachineRoot: Hash; + io?: WithdrawIo; projectName: string; + retry?: RetryOptions; }): Promise<{ machineHash: Hash; store: string }> => { - const { application, client, finalizedMachineRoot, projectName } = options; + const { + application, + client, + finalizedMachineRoot, + io = defaultIo, + projectName, + retry = FINALIZED_EPOCH_RETRY, + } = options; const machineHash = finalizedMachineRoot; if (isSameHash(finalizedMachineRoot, zeroHash)) { @@ -144,14 +189,15 @@ const replayLastFinalizedEpoch = async (options: { application, client, finalizedMachineRoot, + retry, }); } catch (error: unknown) { progress.fail(); throw error; } - const store = `${WORK_DIR}/${application}/epoch-${epochIndex}`; - if (await nodePathExists({ path: store, projectName })) { + const store = replayStorePath(application, epochIndex); + if (await io.nodePathExists({ path: store, projectName })) { progress.succeed( `Using the machine replayed up to epoch ${chalk.cyan(epochIndex)}`, ); @@ -162,8 +208,8 @@ const replayLastFinalizedEpoch = async (options: { const tmp = `${store}.tmp`; progress.text = `Replaying the inputs up to epoch ${chalk.cyan(epochIndex)}, this may take a while...`; try { - await removeNodePath({ path: tmp, projectName }); - const replay = await replayMachine({ + await io.removeNodePath({ path: tmp, projectName }); + const replay = await io.replayMachine({ application, epochIndex, projectName, @@ -174,18 +220,71 @@ const replayLastFinalizedEpoch = async (options: { `Replayed machine root ${replay.machineRoot} does not match the machine hash ${machineHash} of epoch ${epochIndex}`, ); } - await execNodeCommand({ command: ["mv", tmp, store], projectName }); + await io.execNodeCommand({ command: ["mv", tmp, store], projectName }); progress.succeed( `Replayed ${chalk.cyan(replay.processedInputs)} inputs up to epoch ${chalk.cyan(epochIndex)}`, ); } catch (error: unknown) { progress.fail("Failed to replay the application"); - await removeNodePath({ path: tmp, projectName }); + await io.removeNodePath({ path: tmp, projectName }); throw error; } return { machineHash, store }; }; +/** + * Generate the accounts drive proofs of an account from the replayed machine, + * checking they prove the finalized machine root + * @returns the paths of the proofs inside the rollups node container + */ +export const generateAccountProofs = async (options: { + account: Address; + application: Address; + driveConfig: AccountsDriveConfig; + io?: WithdrawIo; + machineHash: Hash; + projectName: string; + store: string; +}) => { + const { + account, + application, + driveConfig, + io = defaultIo, + machineHash, + projectName, + store, + } = options; + const progress = ora( + `Generating the proofs of ${chalk.cyan(account)}...`, + ).start(); + const proof = await io + .proveAccountsDrive({ + account, + driveConfig, + outDir: `${WORK_DIR}/${application}/${account}`, + projectName, + snapshot: store, + }) + .catch((error: unknown) => { + progress.fail(`Failed to generate the proofs of ${account}`); + throw error; + }); + if (!isSameHash(proof.machineRoot, machineHash)) { + progress.fail(); + // a cached snapshot that doesn't prove the finalized root would fail + // every retry, so drop it and let the next run replay + await io.removeNodePath({ path: store, projectName }); + throw new Error( + `Proof machine root ${proof.machineRoot} does not match the finalized machine root ${machineHash}. Removed the cached snapshot; run the command again to replay it`, + ); + } + progress.succeed( + `Generated the proofs of ${chalk.cyan(account)} (account index ${chalk.cyan(proof.accountIndex)})`, + ); + return proof; +}; + /** * Generate the proofs of an account and prove the accounts drive root, if * nobody did it yet @@ -222,31 +321,14 @@ const proveAccount = async (options: { projectName, }); - const progress = ora( - `Generating the proofs of ${chalk.cyan(account)}...`, - ).start(); - const proof = await proveAccountsDrive({ + const proof = await generateAccountProofs({ account, + application, driveConfig: withdrawalConfig, - outDir: `${WORK_DIR}/${application}/${account}`, + machineHash, projectName, - snapshot: store, - }).catch((error: unknown) => { - progress.fail(`Failed to generate the proofs of ${account}`); - throw error; + store, }); - if (!isSameHash(proof.machineRoot, machineHash)) { - progress.fail(); - // a cached snapshot that doesn't prove the finalized root would fail - // every retry, so drop it and let the next run replay - await removeNodePath({ path: store, projectName }); - throw new Error( - `Proof machine root ${proof.machineRoot} does not match the finalized machine root ${machineHash}. Removed the cached snapshot; run the command again to replay it`, - ); - } - progress.succeed( - `Generated the proofs of ${chalk.cyan(account)} (account index ${chalk.cyan(proof.accountIndex)})`, - ); if (!driveRootProven) { // the withdrawal depends on it, so always wait for the receipt, and diff --git a/apps/cli/src/node.ts b/apps/cli/src/node.ts index 8e387b86..999b7de1 100644 --- a/apps/cli/src/node.ts +++ b/apps/cli/src/node.ts @@ -29,11 +29,19 @@ export const getNodeClient = async (options: { */ export const resolveNodeApplication = async (options: { application?: string; + lookup?: { + getApplicationAddress: typeof getApplicationAddress; + getServiceState: typeof getServiceState; + }; projectName: string; }): Promise
=> { - const { application, projectName } = options; + const { + application, + lookup = { getApplicationAddress, getServiceState }, + projectName, + } = options; - const state = await getServiceState({ + const state = await lookup.getServiceState({ projectName, service: "rollups_node", }); @@ -50,7 +58,7 @@ export const resolveNodeApplication = async (options: { return getAddress(application); } - const address = await getApplicationAddress({ projectName }); + const address = await lookup.getApplicationAddress({ projectName }); if (!address) { throw new Error( `No application deployed for the current machine, use ${chalk.cyan("--application")}`, diff --git a/apps/cli/tests/unit/commands/refund.test.ts b/apps/cli/tests/unit/commands/refund.test.ts new file mode 100644 index 00000000..5443de65 --- /dev/null +++ b/apps/cli/tests/unit/commands/refund.test.ts @@ -0,0 +1,100 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { getInputData, parseInputIndex } from "../../../src/commands/refund"; + +const application = "0x1234567890abcdef1234567890abcdef12345678"; + +describe("parseInputIndex", () => { + it("should parse decimal and 0x-prefixed hexadecimal indexes", () => { + expect(parseInputIndex("2")).toBe(2n); + expect(parseInputIndex("0x2a")).toBe(42n); + }); + + it("should reject anything else", () => { + for (const value of ["abc", "-1", "", "1.5", "0x"]) { + expect(() => parseInputIndex(value)).toThrow( + "Not a valid input index", + ); + } + }); +}); + +describe("getInputData", () => { + let dir: string; + const file = (name: string, content: string) => { + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, content); + return filePath; + }; + const unusedClient = { + getInput: () => { + throw new Error("the node must not be queried"); + }, + }; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "refund-")); + }); + + afterAll(() => { + fs.rmSync(dir, { recursive: true }); + }); + + it("should use the input file, trimmed, instead of the node", async () => { + expect( + await getInputData({ + application, + client: unusedClient, + inputFile: file("valid.hex", " 0xdeadbeef\n"), + inputIndex: 2n, + projectName: "dapp", + }), + ).toBe("0xdeadbeef"); + }); + + it("should reject an input file that isn't 0x-prefixed hex", async () => { + for (const content of ["deadbeef", "0xnothex", "0x", ""]) { + await expect( + getInputData({ + application, + client: unusedClient, + inputFile: file("invalid.hex", content), + inputIndex: 2n, + projectName: "dapp", + }), + ).rejects.toThrow("must contain the 0x-prefixed hexadecimal"); + } + }); + + it("should read the input from the node", async () => { + const client = { + getInput: async () => ({ rawData: "0xcafe" as const }), + }; + expect( + await getInputData({ + application, + client: client as never, + inputIndex: 2n, + projectName: "dapp", + }), + ).toBe("0xcafe"); + }); + + it("should point to --input-file when the node doesn't have the input", async () => { + const client = { + getInput: async () => { + throw new Error("not found"); + }, + }; + await expect( + getInputData({ + application, + client: client as never, + inputIndex: 99n, + projectName: "dapp", + }), + ).rejects.toThrow(/Input 99 not found in the node, use .*--input-file/); + }); +}); diff --git a/apps/cli/tests/unit/commands/withdraw.test.ts b/apps/cli/tests/unit/commands/withdraw.test.ts new file mode 100644 index 00000000..2a9bcbfd --- /dev/null +++ b/apps/cli/tests/unit/commands/withdraw.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from "bun:test"; +import { type Hash, toHex, zeroHash } from "viem"; +import { + generateAccountProofs, + replayLastFinalizedEpoch, + replayStorePath, + type WithdrawIo, +} from "../../../src/commands/withdraw"; + +const application = "0x1234567890abcdef1234567890abcdef12345678"; +const account = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const root = toHex(1, { size: 32 }); +const otherRoot = toHex(2, { size: 32 }); +const fastRetry = { retries: 2, minTimeout: 1, maxTimeout: 1 }; + +/** Node client answering with the given (epoch index, machine hash) in turn */ +const nodeClient = (...epochs: [bigint, Hash | null][]) => { + let call = 0; + return { + getLastAcceptedEpochIndex: async () => + epochs[Math.min(call++, epochs.length - 1)][0], + getEpoch: async ({ epochIndex }: { epochIndex: bigint }) => ({ + machineHash: epochs.find(([index]) => index === epochIndex)?.[1], + }), + } as never; +}; + +/** I/O that records its calls, with the given cache state and results */ +const recordingIo = (options: { + cached?: boolean; + proofRoot?: Hash; + replayRoot?: Hash; +}) => { + const calls: string[] = []; + const io: WithdrawIo = { + execNodeCommand: async ({ command }) => { + calls.push(command.join(" ")); + return ""; + }, + nodePathExists: async ({ path }) => { + calls.push(`exists ${path}`); + return options.cached ?? false; + }, + proveAccountsDrive: async () => { + calls.push("prove"); + return { + accountIndex: 0n, + accountsDriveMerkleRoot: root, + driveRootProofFile: "/tmp/drive-root-proof.json", + machineRoot: options.proofRoot ?? root, + withdrawProofFile: "/tmp/withdraw-proof.json", + }; + }, + removeNodePath: async ({ path }) => { + calls.push(`rm ${path}`); + return ""; + }, + replayMachine: async ({ epochIndex }) => { + calls.push(`replay ${epochIndex}`); + return { + machineRoot: options.replayRoot ?? root, + processedInputs: 2, + store: "", + }; + }, + }; + return { calls, io }; +}; + +describe("replayStorePath", () => { + it("should key the snapshot by application and epoch", () => { + expect(replayStorePath(application, 7n)).toBe( + `/tmp/cartesi-withdraw/${application}/epoch-7`, + ); + }); +}); + +describe("replayLastFinalizedEpoch", () => { + const store = replayStorePath(application, 1n); + + it("should stop when nothing was finalized on chain", async () => { + const { calls, io } = recordingIo({}); + await expect( + replayLastFinalizedEpoch({ + application, + client: nodeClient(), + finalizedMachineRoot: zeroHash, + io, + projectName: "dapp", + }), + ).rejects.toThrow("there are no funds to withdraw"); + expect(calls).toEqual([]); + }); + + it("should reuse a cached snapshot", async () => { + const { calls, io } = recordingIo({ cached: true }); + expect( + await replayLastFinalizedEpoch({ + application, + client: nodeClient([1n, root]), + finalizedMachineRoot: root, + io, + projectName: "dapp", + }), + ).toEqual({ machineHash: root, store }); + expect(calls).toEqual([`exists ${store}`]); + }); + + it("should replay into a temporary path and move it into the cache", async () => { + const { calls, io } = recordingIo({}); + expect( + await replayLastFinalizedEpoch({ + application, + client: nodeClient([1n, root]), + finalizedMachineRoot: root, + io, + projectName: "dapp", + }), + ).toEqual({ machineHash: root, store }); + expect(calls).toEqual([ + `exists ${store}`, + `rm ${store}.tmp`, + "replay 1", + `mv ${store}.tmp ${store}`, + ]); + }); + + it("should not cache a replay whose root isn't the finalized one", async () => { + const { calls, io } = recordingIo({ replayRoot: otherRoot }); + await expect( + replayLastFinalizedEpoch({ + application, + client: nodeClient([1n, root]), + finalizedMachineRoot: root, + io, + projectName: "dapp", + }), + ).rejects.toThrow("does not match"); + expect(calls).toEqual([ + `exists ${store}`, + `rm ${store}.tmp`, + "replay 1", + `rm ${store}.tmp`, + ]); + }); + + it("should wait for the node to reach the finalized epoch", async () => { + const { calls, io } = recordingIo({}); + expect( + await replayLastFinalizedEpoch({ + application, + client: nodeClient([0n, otherRoot], [1n, root]), + finalizedMachineRoot: root, + io, + projectName: "dapp", + retry: fastRetry, + }), + ).toEqual({ machineHash: root, store }); + expect(calls).toContain("replay 1"); + }); + + it("should stop when the node never reaches the finalized epoch", async () => { + const { calls, io } = recordingIo({}); + await expect( + replayLastFinalizedEpoch({ + application, + client: nodeClient([0n, otherRoot]), + finalizedMachineRoot: root, + io, + projectName: "dapp", + retry: fastRetry, + }), + ).rejects.toThrow("hasn't processed the last finalized epoch yet"); + expect(calls).toEqual([]); + }); +}); + +describe("generateAccountProofs", () => { + const store = replayStorePath(application, 1n); + const driveConfig = { + accountsDriveStartIndex: 0x240n, + log2LeavesPerAccount: 0n, + log2MaxNumOfAccounts: 17n, + }; + + it("should return the proofs of the finalized machine", async () => { + const { calls, io } = recordingIo({}); + const proof = await generateAccountProofs({ + account, + application, + driveConfig, + io, + machineHash: root, + projectName: "dapp", + store, + }); + expect(proof.withdrawProofFile).toBe("/tmp/withdraw-proof.json"); + expect(calls).toEqual(["prove"]); + }); + + it("should discard the cached snapshot when its proof doesn't match", async () => { + const { calls, io } = recordingIo({ proofRoot: otherRoot }); + await expect( + generateAccountProofs({ + account, + application, + driveConfig, + io, + machineHash: root, + projectName: "dapp", + store, + }), + ).rejects.toThrow("Removed the cached snapshot"); + expect(calls).toEqual(["prove", `rm ${store}`]); + }); +}); diff --git a/apps/cli/tests/unit/node.test.ts b/apps/cli/tests/unit/node.test.ts index 9c1967b9..f085435c 100644 --- a/apps/cli/tests/unit/node.test.ts +++ b/apps/cli/tests/unit/node.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { parseAccountIndex } from "../../src/node"; +import { parseAccountIndex, resolveNodeApplication } from "../../src/node"; describe("parseAccountIndex", () => { it("should parse a non-negative integer", () => { @@ -13,3 +13,58 @@ describe("parseAccountIndex", () => { expect(() => parseAccountIndex("one")).toThrow(); }); }); + +describe("resolveNodeApplication", () => { + const deployed = "0x1234567890abcdef1234567890abcdef12345678"; + const lookup = (state: string | undefined, address?: string) => ({ + getApplicationAddress: async () => address as never, + getServiceState: async () => state, + }); + + it("should tell to start the environment when the node isn't running", async () => { + await expect( + resolveNodeApplication({ + lookup: lookup("exited", deployed), + projectName: "dapp", + }), + ).rejects.toThrow(/is not running, use .*cartesi run/); + }); + + it("should use the given application address", async () => { + expect( + await resolveNodeApplication({ + application: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + lookup: lookup("running", deployed), + projectName: "dapp", + }), + ).toBe("0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD"); + }); + + it("should reject an invalid application address", async () => { + await expect( + resolveNodeApplication({ + application: "0x123", + lookup: lookup("running", deployed), + projectName: "dapp", + }), + ).rejects.toThrow("Invalid application address 0x123"); + }); + + it("should use the application deployed for the current machine", async () => { + expect( + await resolveNodeApplication({ + lookup: lookup("running", deployed), + projectName: "dapp", + }), + ).toBe(deployed); + }); + + it("should tell to pass --application when none is deployed", async () => { + await expect( + resolveNodeApplication({ + lookup: lookup("running"), + projectName: "dapp", + }), + ).rejects.toThrow(/No application deployed .*--application/); + }); +}); From 47d9d80fe4ade4933157623b4c744ef157ae6c0b Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 6 Oct 2026 15:30:42 +0100 Subject: [PATCH 13/25] fix(cli): explain why a proof file needs a proven drive root --- apps/cli/src/commands/withdraw.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/commands/withdraw.ts b/apps/cli/src/commands/withdraw.ts index 5dab7b62..143bfbcb 100644 --- a/apps/cli/src/commands/withdraw.ts +++ b/apps/cli/src/commands/withdraw.ts @@ -413,7 +413,7 @@ export const createWithdrawCommand = () => { if (proofFile) { if (!driveRootProven) { throw new Error( - `The accounts drive root of ${application} is not proven yet, use ${chalk.cyan("--account")} so it can be proven`, + `The accounts drive root of ${application} isn't proven yet. ${chalk.cyan("--proof-file")} only carries the account proof; run ${chalk.cyan("cartesi withdraw --account
")} once so the CLI proves the drive root, then ${chalk.cyan("--proof-file")} will work`, ); } tmpProofFile = await writeNodeTempFile({ From 3006d188a63db61b3669406b6b18810593267a2c Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 6 Oct 2026 15:53:18 +0100 Subject: [PATCH 14/25] test(cli): cover recovery error paths against the devnet --- .../integration/commands/recovery.test.ts | 314 ++++++++++++++---- 1 file changed, 244 insertions(+), 70 deletions(-) diff --git a/apps/cli/tests/integration/commands/recovery.test.ts b/apps/cli/tests/integration/commands/recovery.test.ts index 9ff8cb63..3118f60c 100644 --- a/apps/cli/tests/integration/commands/recovery.test.ts +++ b/apps/cli/tests/integration/commands/recovery.test.ts @@ -9,6 +9,7 @@ import { type Address, createTestClient, http, + parseEther, publicActions, walletActions, } from "viem"; @@ -21,9 +22,12 @@ import { testUsdcAddress, testUsdWithdrawalOutputBuilderAddress, } from "../../../src/contracts"; +import { replayStorePath } from "../../../src/commands/withdraw"; import { + execNodeCommand, getDeployments, getProjectPort, + proveAccountsDrive, stopEnvironment, } from "../../../src/exec/rollups"; import { cartesi } from "../../../src/wallet"; @@ -35,9 +39,11 @@ import { TEST_RUNTIME_VERSION, TEST_SDK } from "../config"; * application in its accounts drive, are withdrawn from proofs generated by * replaying the application. * - * The runtime must ship a rollups node with the refund command, e.g. the - * images built from rollups-node#798 with CARTESI_TEST_SDK=cartesi/sdk:798 and - * CARTESI_TEST_RUNTIME_VERSION=798. + * The runtime must ship a rollups node with the refund command, as the + * default SDK does. + * + * Inputs: alice (0), bob (1) and erin (2) are finalized in epoch 0; carol (3) + * and dave (4) stay in the open epoch 1 and are refunded. */ const EPOCH_LENGTH = 720; @@ -50,11 +56,19 @@ const projectName = `recovery-${process.pid}`; const account = (addressIndex: number) => mnemonicToAccount(DEVNET_MNEMONIC, { addressIndex }).address; const alice = account(0); -const guardian = account(1); const bob = account(2); const carol = account(3); +const dave = account(4); +const erin = account(5); + +// the guardian comes from a mnemonic the node doesn't sign with +const guardianMnemonic = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; +const guardian = mnemonicToAccount(guardianMnemonic).address; -const amounts = { alice: 100n, bob: 50n, carol: 25n }; +const amounts = { alice: 100n, bob: 50n, carol: 25n, dave: 15n, erin: 10n }; + +const count = (text: string, part: string) => text.split(part).length - 1; /** * Layout of the accounts drive in the built machine: its size in log2 of the @@ -82,13 +96,23 @@ describe("fund recovery", () => { let anvil: Awaited>; let node: ReturnType; - const cli = (args: string[]) => + const cli = (args: string[], env?: Record) => execa("node", [cliPath, ...args, "--project-name", projectName], { all: true, cwd: appDir, + env, reject: false, }); + // temporary files written into the node container with mktemp + const nodeTempFiles = async () => + Number( + await execNodeCommand({ + command: ["sh", "-c", "ls /tmp/tmp.* 2>/dev/null | wc -l"], + projectName, + }), + ); + const createAnvilClient = async () => { const host = await getProjectPort({ projectName }); return createTestClient({ @@ -246,6 +270,7 @@ withdrawal_output_builder = "${testUsdWithdrawalOutputBuilderAddress}" async () => { await deposit(alice, amounts.alice); await deposit(bob, amounts.bob); + await deposit(erin, amounts.erin); // close the first epoch and wait for its claim to be accepted await anvil.mine({ blocks: EPOCH_LENGTH }); @@ -256,83 +281,232 @@ withdrawal_output_builder = "${testUsdWithdrawalOutputBuilderAddress}" expect(epochIndex).toBe(0n); console.log("✓ Epoch 0 accepted"); expect(await usdcBalance(alice)).toBe(0n); - }, - TIMEOUT, - ); - it( - "should not refund before the application is foreclosed", - async () => { + // these stay in the open epoch, never finalized await deposit(carol, amounts.carol); - await waitFor(() => node.getInput({ application, inputIndex: 2n })); - - const refund = await cli(["refund", "2", "--yes"]); - expect(refund.exitCode).not.toBe(0); - expect(refund.all).toContain("NotForeclosed"); + await deposit(dave, amounts.dave); + await waitFor(() => node.getInput({ application, inputIndex: 4n })); }, TIMEOUT, ); - it( - "should foreclose signing as the guardian", - async () => { - const foreclose = await cli(["foreclose", "--yes"]); - expect(foreclose.exitCode, foreclose.all).toBe(0); - }, - TIMEOUT, - ); + describe("before the foreclosure", () => { + it( + "should pass the node's NotForeclosed error through on refund", + async () => { + const refund = await cli(["refund", "3", "--yes"]); + expect(refund.exitCode).not.toBe(0); + expect(refund.all).toContain("NotForeclosed"); + }, + TIMEOUT, + ); + + it( + "should not withdraw from an application that isn't foreclosed", + async () => { + const withdraw = await cli([ + "withdraw", + "--account", + alice, + "--yes", + ]); + expect(withdraw.exitCode).not.toBe(0); + expect(withdraw.all).toContain("is not foreclosed"); + }, + TIMEOUT, + ); + + it( + "should point to --input-file for an input the node doesn't have", + async () => { + const refund = await cli(["refund", "99", "--yes"]); + expect(refund.exitCode).not.toBe(0); + expect(refund.all).toContain("Input 99 not found in the node"); + }, + TIMEOUT, + ); + }); it( - "should refund the deposit that was not finalized", + "should foreclose as a guardian outside the node signer", async () => { - expect(await usdcBalance(carol)).toBe(0n); - const refund = await cli(["refund", "2", "--yes", "--json"]); - expect(refund.exitCode, refund.all).toBe(0); - expect(JSON.parse(refund.stdout)).toMatchObject({ - input_index: "2", + await anvil.setBalance({ + address: guardian, + value: parseEther("1"), }); - expect(await usdcBalance(carol)).toBe(amounts.carol); + const foreclose = await cli(["foreclose", "--yes"], { + CARTESI_AUTH_MNEMONIC: guardianMnemonic, + }); + expect(foreclose.exitCode, foreclose.all).toBe(0); }, TIMEOUT, ); - it( - "should withdraw the finalized balance of an account", - async () => { - expect(await usdcBalance(alice)).toBe(0n); - const withdraw = await cli([ - "withdraw", - "--account", - alice, - "--yes", - "--json", - ]); - expect(withdraw.exitCode, withdraw.all).toBe(0); - expect(withdraw.stderr).toContain("Replayed"); - const result = JSON.parse(withdraw.stdout); - expect(result.application_address.toLowerCase()).toBe( - application.toLowerCase(), - ); - expect(await usdcBalance(alice)).toBe(amounts.alice); - }, - TIMEOUT, - ); + describe("after the foreclosure", () => { + it( + "should not withdraw with a proof file before the drive root is proven", + async () => { + const proofFile = path.join(appDir, "unused-proof.json"); + await fs.writeFile(proofFile, "{}"); + const withdraw = await cli([ + "withdraw", + "--proof-file", + proofFile, + "--yes", + ]); + expect(withdraw.exitCode).not.toBe(0); + expect(withdraw.all).toContain("isn't proven yet"); + }, + TIMEOUT, + ); - it( - "should reuse the replayed machine for another account", - async () => { - expect(await usdcBalance(bob)).toBe(0n); - const withdraw = await cli([ - "withdraw", - "--account", - bob, - "--yes", - "--json", - ]); - expect(withdraw.exitCode, withdraw.all).toBe(0); - expect(withdraw.stderr).toContain("Using the machine replayed"); - expect(await usdcBalance(bob)).toBe(amounts.bob); - }, - TIMEOUT, - ); + it( + "should refund a deposit that was not finalized", + async () => { + expect(await usdcBalance(carol)).toBe(0n); + const refund = await cli(["refund", "3", "--yes", "--json"]); + expect(refund.exitCode, refund.all).toBe(0); + expect(JSON.parse(refund.stdout)).toMatchObject({ + input_index: "3", + }); + expect(await usdcBalance(carol)).toBe(amounts.carol); + }, + TIMEOUT, + ); + + it( + "should refund with the input bytes from a file", + async () => { + const { rawData } = await node.getInput({ + application, + inputIndex: 4n, + }); + const inputFile = path.join(appDir, "dave-input.hex"); + await fs.writeFile(inputFile, rawData); + expect(await usdcBalance(dave)).toBe(0n); + const refund = await cli([ + "refund", + "4", + "--input-file", + inputFile, + "--yes", + ]); + expect(refund.exitCode, refund.all).toBe(0); + expect(await usdcBalance(dave)).toBe(amounts.dave); + expect(await nodeTempFiles()).toBe(0); + }, + TIMEOUT, + ); + + it( + "should fail to prove an account that isn't in the drive", + async () => { + const withdraw = await cli([ + "withdraw", + "--account", + carol, + "--yes", + ]); + expect(withdraw.exitCode).not.toBe(0); + expect(withdraw.all).toContain( + `Failed to generate the proofs of ${carol}`, + ); + }, + TIMEOUT, + ); + + it( + "should withdraw the finalized balance of an account", + async () => { + expect(await usdcBalance(alice)).toBe(0n); + const withdraw = await cli([ + "withdraw", + "--account", + alice, + "--yes", + "--json", + ]); + expect(withdraw.exitCode, withdraw.all).toBe(0); + // carol's attempt already replayed the finalized epoch + expect(withdraw.stderr).toContain("Using the machine replayed"); + // the drive root is proven first, then the withdrawal + expect(count(withdraw.stderr, "Transaction hash:")).toBe(2); + const result = JSON.parse(withdraw.stdout); + expect(result.application_address.toLowerCase()).toBe( + application.toLowerCase(), + ); + expect(await usdcBalance(alice)).toBe(amounts.alice); + }, + TIMEOUT, + ); + + it( + "should prove the drive root only once", + async () => { + expect(await usdcBalance(bob)).toBe(0n); + const withdraw = await cli([ + "withdraw", + "--account", + bob, + "--yes", + "--json", + ]); + expect(withdraw.exitCode, withdraw.all).toBe(0); + expect(withdraw.stderr).toContain("Using the machine replayed"); + expect(count(withdraw.stderr, "Transaction hash:")).toBe(1); + expect(await usdcBalance(bob)).toBe(amounts.bob); + }, + TIMEOUT, + ); + + it( + "should withdraw with a proof file", + async () => { + // generate erin's proof from the cached replay, as an outside + // tool would, and withdraw through the file path + const { withdrawalConfig } = await node.getApplication({ + application, + }); + const proof = await proveAccountsDrive({ + account: erin, + driveConfig: withdrawalConfig, + outDir: `/tmp/recovery-test/${erin}`, + projectName, + snapshot: replayStorePath(application, 0n), + }); + const proofFile = path.join(appDir, "erin-proof.json"); + await fs.writeFile( + proofFile, + await execNodeCommand({ + command: ["cat", proof.withdrawProofFile], + projectName, + }), + ); + + expect(await usdcBalance(erin)).toBe(0n); + const withdraw = await cli([ + "withdraw", + "--proof-file", + proofFile, + "--yes", + ]); + expect(withdraw.exitCode, withdraw.all).toBe(0); + expect(await usdcBalance(erin)).toBe(amounts.erin); + expect(await nodeTempFiles()).toBe(0); + }, + TIMEOUT, + ); + }); + + it("should reject --account together with --proof-file", async () => { + const withdraw = await cli([ + "withdraw", + "--account", + alice, + "--proof-file", + "proof.json", + ]); + expect(withdraw.exitCode).not.toBe(0); + expect(withdraw.all).toContain("cannot be used with option"); + }); }); From 71cf9fcef56aaacb443a416964182d430b019879 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 6 Oct 2026 17:30:27 +0100 Subject: [PATCH 15/25] refactor(cli): split the node container and machine tool helpers out of exec/rollups --- apps/cli/src/commands/foreclose.ts | 2 +- apps/cli/src/commands/refund.ts | 2 +- apps/cli/src/commands/withdraw.ts | 10 +- .../src/exec/cartesi-rollups-machine-tool.ts | 179 ++++++++ apps/cli/src/exec/node-container.ts | 238 ++++++++++ apps/cli/src/exec/rollups.ts | 418 +----------------- .../integration/commands/recovery.test.ts | 4 +- .../exec/cartesi-rollups-machine-tool.test.ts | 140 ++++++ .../tests/unit/exec/node-container.test.ts | 218 +++++++++ apps/cli/tests/unit/exec/rollups.test.ts | 352 --------------- 10 files changed, 786 insertions(+), 777 deletions(-) create mode 100644 apps/cli/src/exec/cartesi-rollups-machine-tool.ts create mode 100644 apps/cli/src/exec/node-container.ts create mode 100644 apps/cli/tests/unit/exec/cartesi-rollups-machine-tool.test.ts create mode 100644 apps/cli/tests/unit/exec/node-container.test.ts diff --git a/apps/cli/src/commands/foreclose.ts b/apps/cli/src/commands/foreclose.ts index 9cefc202..79c2ff92 100644 --- a/apps/cli/src/commands/foreclose.ts +++ b/apps/cli/src/commands/foreclose.ts @@ -19,7 +19,7 @@ import { type NodeSigner, runNodeCommand, transactionArgs, -} from "../exec/rollups.js"; +} from "../exec/node-container.js"; import { getNodeClient, parseAccountIndex, diff --git a/apps/cli/src/commands/refund.ts b/apps/cli/src/commands/refund.ts index 72c26d25..ee60d16b 100644 --- a/apps/cli/src/commands/refund.ts +++ b/apps/cli/src/commands/refund.ts @@ -14,7 +14,7 @@ import { runNodeCommand, transactionArgs, writeNodeTempFile, -} from "../exec/rollups.js"; +} from "../exec/node-container.js"; import { getNodeClient, parseAccountIndex, diff --git a/apps/cli/src/commands/withdraw.ts b/apps/cli/src/commands/withdraw.ts index 143bfbcb..a7f869b1 100644 --- a/apps/cli/src/commands/withdraw.ts +++ b/apps/cli/src/commands/withdraw.ts @@ -11,18 +11,20 @@ import { iOutputsMerkleRootValidatorAbi, } from "../contracts.js"; import { - type AccountsDriveConfig, execNodeCommand, handleNodeCommandError, nodePathExists, - proveAccountsDrive, removeNodePath, - replayMachine, runNodeCommand, type TransactionOptions, transactionArgs, writeNodeTempFile, -} from "../exec/rollups.js"; +} from "../exec/node-container.js"; +import { + type AccountsDriveConfig, + proveAccountsDrive, + replayMachine, +} from "../exec/cartesi-rollups-machine-tool.js"; import { getNodeClient, parseAccountIndex, diff --git a/apps/cli/src/exec/cartesi-rollups-machine-tool.ts b/apps/cli/src/exec/cartesi-rollups-machine-tool.ts new file mode 100644 index 00000000..27fc525a --- /dev/null +++ b/apps/cli/src/exec/cartesi-rollups-machine-tool.ts @@ -0,0 +1,179 @@ +import { dirname } from "node:path/posix"; +import { type Address, type Hash, isHash, toHex } from "viem"; +import { execNodeCommand } from "./node-container.js"; + +/** + * Path of the machine template inside the rollups node container, as + * published by `cartesi run` + */ +export const NODE_TEMPLATE_PATH = + "/var/lib/cartesi-rollups-node/snapshots/image"; + +/** + * Layout of the accounts drive, as configured in the application contract + */ +export type AccountsDriveConfig = { + accountsDriveStartIndex: bigint; + log2LeavesPerAccount: bigint; + log2MaxNumOfAccounts: bigint; +}; + +export const replayArgs = (options: { + application: Address; + epochIndex: bigint; + store: string; +}): string[] => [ + "cartesi-rollups-machine-tool", + "replay", + "--template", + NODE_TEMPLATE_PATH, + "--application", + options.application, + "--to-epoch", + options.epochIndex.toString(), + "--store", + options.store, +]; + +export const proveAccountsDriveArgs = (options: { + account: Address; + driveConfig: AccountsDriveConfig; + outDir: string; + snapshot: string; +}): string[] => { + const { account, driveConfig, outDir, snapshot } = options; + return [ + "cartesi-rollups-machine-tool", + "prove", + "accounts-drive", + "--snapshot", + snapshot, + "--accounts-drive-start-index", + toHex(driveConfig.accountsDriveStartIndex), + "--log2-max-num-of-accounts", + toHex(driveConfig.log2MaxNumOfAccounts), + "--log2-leaves-per-account", + toHex(driveConfig.log2LeavesPerAccount), + "--account", + account, + "--out-drive-root-proof", + `${outDir}/drive-root-proof.json`, + "--out-withdraw-proof", + `${outDir}/withdraw-proof.json`, + ]; +}; + +export type ReplaySummary = { + machineRoot: Hash; + processedInputs: number; + store: string; +}; + +export type ProveSummary = { + accountIndex: bigint; + accountsDriveMerkleRoot: Hash; + driveRootProofFile: string; + machineRoot: Hash; + withdrawProofFile: string; +}; + +/** + * The machine tool prints a single JSON object as its last line of output + */ +const parseSummary = (stdout: string): Record => { + const line = stdout.trim().split("\n").pop() ?? ""; + const summary = JSON.parse(line); + if (typeof summary !== "object" || summary === null) { + throw new Error(`Unexpected machine tool output: ${stdout}`); + } + return summary; +}; + +const requireHash = (summary: Record, key: string): Hash => { + const value = summary[key]; + if (typeof value !== "string" || !isHash(value)) { + throw new Error(`Invalid ${key} in machine tool output: ${value}`); + } + return value; +}; + +const requireString = ( + summary: Record, + key: string, +): string => { + const value = summary[key]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Missing ${key} in machine tool output`); + } + return value; +}; + +export const parseReplaySummary = (stdout: string): ReplaySummary => { + const summary = parseSummary(stdout); + const processedInputs = summary.processed_inputs; + if (typeof processedInputs !== "number") { + throw new Error("Missing processed_inputs in machine tool output"); + } + return { + machineRoot: requireHash(summary, "machine_root"), + processedInputs, + store: requireString(summary, "store"), + }; +}; + +export const parseProveSummary = (stdout: string): ProveSummary => { + const summary = parseSummary(stdout); + return { + accountIndex: BigInt(requireString(summary, "account_index")), + accountsDriveMerkleRoot: requireHash( + summary, + "accounts_drive_merkle_root", + ), + driveRootProofFile: requireString(summary, "drive_root_proof_file"), + machineRoot: requireHash(summary, "machine_root"), + withdrawProofFile: requireString(summary, "withdraw_proof_file"), + }; +}; + +/** + * Replay the accepted inputs of an application up to an epoch, storing the + * resulting machine inside the rollups node container. The machine writes the + * reports of the inputs to its working directory, which must be writable. + */ +export const replayMachine = async (options: { + application: Address; + epochIndex: bigint; + projectName: string; + store: string; +}): Promise => { + const { projectName, store } = options; + const workdir = dirname(store); + await execNodeCommand({ command: ["mkdir", "-p", workdir], projectName }); + const stdout = await execNodeCommand({ + command: replayArgs(options), + projectName, + workdir, + }); + return parseReplaySummary(stdout); +}; + +/** + * Generate the accounts drive root proof and the withdraw proof of an account + * from a stored machine inside the rollups node container + */ +export const proveAccountsDrive = async (options: { + account: Address; + driveConfig: AccountsDriveConfig; + outDir: string; + projectName: string; + snapshot: string; +}): Promise => { + const { outDir, projectName } = options; + await execNodeCommand({ command: ["mkdir", "-p", outDir], projectName }); + const stdout = await execNodeCommand({ + command: proveAccountsDriveArgs(options), + projectName, + workdir: outDir, + }); + return parseProveSummary(stdout); +}; diff --git a/apps/cli/src/exec/node-container.ts b/apps/cli/src/exec/node-container.ts new file mode 100644 index 00000000..46ec1198 --- /dev/null +++ b/apps/cli/src/exec/node-container.ts @@ -0,0 +1,238 @@ +import { ExecaError, execa } from "execa"; + +/** + * Transaction options shared by the cartesi-rollups-cli commands that send a + * transaction. Each one is forwarded only when set. + */ +export type TransactionOptions = { + json?: boolean; + wait?: boolean; + waitTimeout?: string; + yes?: boolean; +}; + +export const transactionArgs = (options: TransactionOptions): string[] => { + const { json, wait, waitTimeout, yes } = options; + const args: string[] = []; + if (yes) { + args.push("--yes"); + } + if (json) { + args.push("--json"); + } + if (wait === false) { + args.push("--no-wait"); + } + if (waitTimeout) { + args.push("--wait-timeout", waitTimeout); + } + return args; +}; + +/** + * Build the docker arguments to run a command inside the rollups node + * container. Without a terminal attached docker cannot allocate a TTY, so it + * must be disabled. + */ +export const nodeExecArgs = (options: { + accountIndex?: number; + command: string[]; + env?: Record; + interactive: boolean; + projectName: string; + workdir?: string; +}): string[] => { + const { accountIndex, command, env, interactive, projectName, workdir } = + options; + const args = ["compose", "--project-name", projectName, "exec"]; + if (!interactive) { + args.push("-T"); + } + // only the names: docker takes the values from its own environment, so + // secrets never show up in the process arguments + for (const name of Object.keys(env ?? {})) { + args.push("-e", name); + } + if (accountIndex !== undefined) { + args.push("-e", `CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=${accountIndex}`); + } + if (workdir) { + args.push("--workdir", workdir); + } + return [...args, "rollups_node", ...command]; +}; + +/** + * Run a command inside the rollups node container with the terminal attached, + * so its prompts and output reach the user directly. Its output can be sent to + * stderr to keep stdout for the output of a later command. + */ +export const runNodeCommand = (options: { + accountIndex?: number; + command: string[]; + env?: Record; + projectName: string; + stdout?: "stdout" | "stderr"; +}) => + execa( + "docker", + nodeExecArgs({ ...options, interactive: !!process.stdin.isTTY }), + { + env: options.env, + stdin: "inherit", + stdout: options.stdout === "stderr" ? 2 : "inherit", + stderr: "inherit", + }, + ); + +/** + * Run a command inside the rollups node container and capture its output + * @returns stdout of the command + */ +export const execNodeCommand = async (options: { + command: string[]; + input?: string; + projectName: string; + workdir?: string; +}): Promise => { + const { input, ...rest } = options; + try { + const { stdout } = await execa( + "docker", + nodeExecArgs({ ...rest, interactive: false }), + { input }, + ); + return stdout; + } catch (error: unknown) { + if (error instanceof ExecaError) { + throw new Error( + String(error.stderr ?? "").trim() || error.shortMessage, + ); + } + throw error; + } +}; + +/** + * Write content to a fresh temporary file inside the rollups node container, + * so the node tools can read it + * @returns path of the file inside the container + */ +export const writeNodeTempFile = async (options: { + content: string; + projectName: string; +}): Promise => { + const { content, projectName } = options; + const stdout = await execNodeCommand({ + command: ["sh", "-c", 'f=$(mktemp) && cat > "$f" && echo "$f"'], + input: content, + projectName, + }); + return stdout.trim(); +}; + +/** + * Remove a file or directory from the rollups node container + */ +export const removeNodePath = (options: { + path: string; + projectName: string; +}) => + execNodeCommand({ + command: ["rm", "-rf", options.path], + projectName: options.projectName, + }); + +/** + * Check whether a path exists inside the rollups node container + */ +export const nodePathExists = async (options: { + path: string; + projectName: string; +}): Promise => { + const { exitCode } = await execa( + "docker", + nodeExecArgs({ + command: ["test", "-e", options.path], + interactive: false, + projectName: options.projectName, + }), + { reject: false }, + ); + return exitCode === 0; +}; + +/** + * Report the failure of a command run with the terminal attached. The command + * already printed its error, so only its exit code is propagated. + */ +export const handleNodeCommandError = (error: unknown) => { + if (error instanceof ExecaError) { + process.exitCode = error.exitCode ?? 1; + return; + } + throw error; +}; + +/** + * Signer the rollups node container is configured with. Private keys are never + * read out of the container, only their kind. + */ +export type NodeSigner = + | { kind: "mnemonic"; mnemonic: string } + | { kind: "private_key" }; + +/** + * Print the signer kind and, for mnemonic signers, the mnemonic itself, the + * same way cartesi-rollups-cli resolves them: an inline mnemonic takes + * precedence over the one in CARTESI_AUTH_MNEMONIC_FILE. + */ +export const NODE_SIGNER_SCRIPT = `kind="\${CARTESI_AUTH_KIND:-mnemonic}" +printf '%s\\n' "$kind" +case "$kind" in +mnemonic|mnemonic_file) + if [ -n "$CARTESI_AUTH_MNEMONIC" ]; then + printf '%s\\n' "$CARTESI_AUTH_MNEMONIC" + elif [ -n "$CARTESI_AUTH_MNEMONIC_FILE" ]; then + cat "$CARTESI_AUTH_MNEMONIC_FILE" + fi + ;; +esac`; + +/** + * Parse the output of the node signer script + */ +export const parseNodeSigner = (stdout: string): NodeSigner => { + const [kind = "", ...rest] = stdout.trim().split("\n"); + switch (kind.trim()) { + case "mnemonic": + case "mnemonic_file": { + const mnemonic = rest.join(" ").trim(); + if (!mnemonic) { + throw new Error("The rollups node has no mnemonic configured"); + } + return { kind: "mnemonic", mnemonic }; + } + case "private_key": + case "private_key_file": + return { kind: "private_key" }; + default: + throw new Error( + `Signer kind ${kind.trim()} of the rollups node isn't supported by the CLI`, + ); + } +}; + +/** + * Read the signer the rollups node container is configured with, which is + * the one cartesi-rollups-cli signs with unless a command overrides it + */ +export const getNodeSigner = async (options: { + projectName: string; +}): Promise => { + const stdout = await execNodeCommand({ + command: ["sh", "-c", NODE_SIGNER_SCRIPT], + projectName: options.projectName, + }); + return parseNodeSigner(stdout); +}; diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index 284a6679..662ed12e 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -1,6 +1,5 @@ import chalk from "chalk"; -import { ExecaError, execa } from "execa"; -import { dirname } from "node:path/posix"; +import { execa } from "execa"; import { Listr, type ListrTask } from "listr2"; import pRetry, { AbortError } from "p-retry"; import { @@ -11,8 +10,6 @@ import { getAddress, hexToNumber, http, - isHash, - toHex, } from "viem"; import { stringify } from "yaml"; import { @@ -714,416 +711,3 @@ export const getAnvilNodeInfo = async (options: { projectName: string }) => { ]); return JSON.parse(stdout); }; - -/** - * Path of the machine template inside the rollups node container, as - * published by `cartesi run` - */ -export const NODE_TEMPLATE_PATH = - "/var/lib/cartesi-rollups-node/snapshots/image"; - -/** - * Transaction options shared by the cartesi-rollups-cli commands that send a - * transaction. Each one is forwarded only when set. - */ -export type TransactionOptions = { - json?: boolean; - wait?: boolean; - waitTimeout?: string; - yes?: boolean; -}; - -export const transactionArgs = (options: TransactionOptions): string[] => { - const { json, wait, waitTimeout, yes } = options; - const args: string[] = []; - if (yes) { - args.push("--yes"); - } - if (json) { - args.push("--json"); - } - if (wait === false) { - args.push("--no-wait"); - } - if (waitTimeout) { - args.push("--wait-timeout", waitTimeout); - } - return args; -}; - -/** - * Build the docker arguments to run a command inside the rollups node - * container. Without a terminal attached docker cannot allocate a TTY, so it - * must be disabled. - */ -export const nodeExecArgs = (options: { - accountIndex?: number; - command: string[]; - env?: Record; - interactive: boolean; - projectName: string; - workdir?: string; -}): string[] => { - const { accountIndex, command, env, interactive, projectName, workdir } = - options; - const args = ["compose", "--project-name", projectName, "exec"]; - if (!interactive) { - args.push("-T"); - } - // only the names: docker takes the values from its own environment, so - // secrets never show up in the process arguments - for (const name of Object.keys(env ?? {})) { - args.push("-e", name); - } - if (accountIndex !== undefined) { - args.push("-e", `CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=${accountIndex}`); - } - if (workdir) { - args.push("--workdir", workdir); - } - return [...args, "rollups_node", ...command]; -}; - -/** - * Run a command inside the rollups node container with the terminal attached, - * so its prompts and output reach the user directly. Its output can be sent to - * stderr to keep stdout for the output of a later command. - */ -export const runNodeCommand = (options: { - accountIndex?: number; - command: string[]; - env?: Record; - projectName: string; - stdout?: "stdout" | "stderr"; -}) => - execa( - "docker", - nodeExecArgs({ ...options, interactive: !!process.stdin.isTTY }), - { - env: options.env, - stdin: "inherit", - stdout: options.stdout === "stderr" ? 2 : "inherit", - stderr: "inherit", - }, - ); - -/** - * Run a command inside the rollups node container and capture its output - * @returns stdout of the command - */ -export const execNodeCommand = async (options: { - command: string[]; - input?: string; - projectName: string; - workdir?: string; -}): Promise => { - const { input, ...rest } = options; - try { - const { stdout } = await execa( - "docker", - nodeExecArgs({ ...rest, interactive: false }), - { input }, - ); - return stdout; - } catch (error: unknown) { - if (error instanceof ExecaError) { - throw new Error( - String(error.stderr ?? "").trim() || error.shortMessage, - ); - } - throw error; - } -}; - -/** - * Write content to a fresh temporary file inside the rollups node container, - * so the node tools can read it - * @returns path of the file inside the container - */ -export const writeNodeTempFile = async (options: { - content: string; - projectName: string; -}): Promise => { - const { content, projectName } = options; - const stdout = await execNodeCommand({ - command: ["sh", "-c", 'f=$(mktemp) && cat > "$f" && echo "$f"'], - input: content, - projectName, - }); - return stdout.trim(); -}; - -/** - * Remove a file or directory from the rollups node container - */ -export const removeNodePath = (options: { - path: string; - projectName: string; -}) => - execNodeCommand({ - command: ["rm", "-rf", options.path], - projectName: options.projectName, - }); - -/** - * Check whether a path exists inside the rollups node container - */ -export const nodePathExists = async (options: { - path: string; - projectName: string; -}): Promise => { - const { exitCode } = await execa( - "docker", - nodeExecArgs({ - command: ["test", "-e", options.path], - interactive: false, - projectName: options.projectName, - }), - { reject: false }, - ); - return exitCode === 0; -}; - -/** - * Report the failure of a command run with the terminal attached. The command - * already printed its error, so only its exit code is propagated. - */ -export const handleNodeCommandError = (error: unknown) => { - if (error instanceof ExecaError) { - process.exitCode = error.exitCode ?? 1; - return; - } - throw error; -}; - -/** - * Signer the rollups node container is configured with. Private keys are never - * read out of the container, only their kind. - */ -export type NodeSigner = - | { kind: "mnemonic"; mnemonic: string } - | { kind: "private_key" }; - -/** - * Print the signer kind and, for mnemonic signers, the mnemonic itself, the - * same way cartesi-rollups-cli resolves them: an inline mnemonic takes - * precedence over the one in CARTESI_AUTH_MNEMONIC_FILE. - */ -export const NODE_SIGNER_SCRIPT = `kind="\${CARTESI_AUTH_KIND:-mnemonic}" -printf '%s\\n' "$kind" -case "$kind" in -mnemonic|mnemonic_file) - if [ -n "$CARTESI_AUTH_MNEMONIC" ]; then - printf '%s\\n' "$CARTESI_AUTH_MNEMONIC" - elif [ -n "$CARTESI_AUTH_MNEMONIC_FILE" ]; then - cat "$CARTESI_AUTH_MNEMONIC_FILE" - fi - ;; -esac`; - -/** - * Parse the output of the node signer script - */ -export const parseNodeSigner = (stdout: string): NodeSigner => { - const [kind = "", ...rest] = stdout.trim().split("\n"); - switch (kind.trim()) { - case "mnemonic": - case "mnemonic_file": { - const mnemonic = rest.join(" ").trim(); - if (!mnemonic) { - throw new Error("The rollups node has no mnemonic configured"); - } - return { kind: "mnemonic", mnemonic }; - } - case "private_key": - case "private_key_file": - return { kind: "private_key" }; - default: - throw new Error( - `Signer kind ${kind.trim()} of the rollups node isn't supported by the CLI`, - ); - } -}; - -/** - * Read the signer the rollups node container is configured with, which is - * the one cartesi-rollups-cli signs with unless a command overrides it - */ -export const getNodeSigner = async (options: { - projectName: string; -}): Promise => { - const stdout = await execNodeCommand({ - command: ["sh", "-c", NODE_SIGNER_SCRIPT], - projectName: options.projectName, - }); - return parseNodeSigner(stdout); -}; - -/** - * Layout of the accounts drive, as configured in the application contract - */ -export type AccountsDriveConfig = { - accountsDriveStartIndex: bigint; - log2LeavesPerAccount: bigint; - log2MaxNumOfAccounts: bigint; -}; - -export const replayArgs = (options: { - application: Address; - epochIndex: bigint; - store: string; -}): string[] => [ - "cartesi-rollups-machine-tool", - "replay", - "--template", - NODE_TEMPLATE_PATH, - "--application", - options.application, - "--to-epoch", - options.epochIndex.toString(), - "--store", - options.store, -]; - -export const proveAccountsDriveArgs = (options: { - account: Address; - driveConfig: AccountsDriveConfig; - outDir: string; - snapshot: string; -}): string[] => { - const { account, driveConfig, outDir, snapshot } = options; - return [ - "cartesi-rollups-machine-tool", - "prove", - "accounts-drive", - "--snapshot", - snapshot, - "--accounts-drive-start-index", - toHex(driveConfig.accountsDriveStartIndex), - "--log2-max-num-of-accounts", - toHex(driveConfig.log2MaxNumOfAccounts), - "--log2-leaves-per-account", - toHex(driveConfig.log2LeavesPerAccount), - "--account", - account, - "--out-drive-root-proof", - `${outDir}/drive-root-proof.json`, - "--out-withdraw-proof", - `${outDir}/withdraw-proof.json`, - ]; -}; - -export type ReplaySummary = { - machineRoot: Hash; - processedInputs: number; - store: string; -}; - -export type ProveSummary = { - accountIndex: bigint; - accountsDriveMerkleRoot: Hash; - driveRootProofFile: string; - machineRoot: Hash; - withdrawProofFile: string; -}; - -/** - * The machine tool prints a single JSON object as its last line of output - */ -const parseSummary = (stdout: string): Record => { - const line = stdout.trim().split("\n").pop() ?? ""; - const summary = JSON.parse(line); - if (typeof summary !== "object" || summary === null) { - throw new Error(`Unexpected machine tool output: ${stdout}`); - } - return summary; -}; - -const requireHash = (summary: Record, key: string): Hash => { - const value = summary[key]; - if (typeof value !== "string" || !isHash(value)) { - throw new Error(`Invalid ${key} in machine tool output: ${value}`); - } - return value; -}; - -const requireString = ( - summary: Record, - key: string, -): string => { - const value = summary[key]; - if (typeof value !== "string" || value.length === 0) { - throw new Error(`Missing ${key} in machine tool output`); - } - return value; -}; - -export const parseReplaySummary = (stdout: string): ReplaySummary => { - const summary = parseSummary(stdout); - const processedInputs = summary.processed_inputs; - if (typeof processedInputs !== "number") { - throw new Error("Missing processed_inputs in machine tool output"); - } - return { - machineRoot: requireHash(summary, "machine_root"), - processedInputs, - store: requireString(summary, "store"), - }; -}; - -export const parseProveSummary = (stdout: string): ProveSummary => { - const summary = parseSummary(stdout); - return { - accountIndex: BigInt(requireString(summary, "account_index")), - accountsDriveMerkleRoot: requireHash( - summary, - "accounts_drive_merkle_root", - ), - driveRootProofFile: requireString(summary, "drive_root_proof_file"), - machineRoot: requireHash(summary, "machine_root"), - withdrawProofFile: requireString(summary, "withdraw_proof_file"), - }; -}; - -/** - * Replay the accepted inputs of an application up to an epoch, storing the - * resulting machine inside the rollups node container. The machine writes the - * reports of the inputs to its working directory, which must be writable. - */ -export const replayMachine = async (options: { - application: Address; - epochIndex: bigint; - projectName: string; - store: string; -}): Promise => { - const { projectName, store } = options; - const workdir = dirname(store); - await execNodeCommand({ command: ["mkdir", "-p", workdir], projectName }); - const stdout = await execNodeCommand({ - command: replayArgs(options), - projectName, - workdir, - }); - return parseReplaySummary(stdout); -}; - -/** - * Generate the accounts drive root proof and the withdraw proof of an account - * from a stored machine inside the rollups node container - */ -export const proveAccountsDrive = async (options: { - account: Address; - driveConfig: AccountsDriveConfig; - outDir: string; - projectName: string; - snapshot: string; -}): Promise => { - const { outDir, projectName } = options; - await execNodeCommand({ command: ["mkdir", "-p", outDir], projectName }); - const stdout = await execNodeCommand({ - command: proveAccountsDriveArgs(options), - projectName, - workdir: outDir, - }); - return parseProveSummary(stdout); -}; diff --git a/apps/cli/tests/integration/commands/recovery.test.ts b/apps/cli/tests/integration/commands/recovery.test.ts index 3118f60c..bad1ce81 100644 --- a/apps/cli/tests/integration/commands/recovery.test.ts +++ b/apps/cli/tests/integration/commands/recovery.test.ts @@ -24,12 +24,12 @@ import { } from "../../../src/contracts"; import { replayStorePath } from "../../../src/commands/withdraw"; import { - execNodeCommand, getDeployments, getProjectPort, - proveAccountsDrive, stopEnvironment, } from "../../../src/exec/rollups"; +import { execNodeCommand } from "../../../src/exec/node-container"; +import { proveAccountsDrive } from "../../../src/exec/cartesi-rollups-machine-tool"; import { cartesi } from "../../../src/wallet"; import { TEST_RUNTIME_VERSION, TEST_SDK } from "../config"; diff --git a/apps/cli/tests/unit/exec/cartesi-rollups-machine-tool.test.ts b/apps/cli/tests/unit/exec/cartesi-rollups-machine-tool.test.ts new file mode 100644 index 00000000..fa490405 --- /dev/null +++ b/apps/cli/tests/unit/exec/cartesi-rollups-machine-tool.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "bun:test"; +import { toHex } from "viem"; +import { + NODE_TEMPLATE_PATH, + parseProveSummary, + parseReplaySummary, + proveAccountsDriveArgs, + replayArgs, +} from "../../../src/exec/cartesi-rollups-machine-tool"; + +const application = "0x1234567890abcdef1234567890abcdef12345678"; +const account = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const hash = (n: number) => toHex(n, { size: 32 }); + +describe("replayArgs", () => { + it("should replay the published template up to the epoch", () => { + expect( + replayArgs({ application, epochIndex: 108n, store: "/tmp/s" }), + ).toEqual([ + "cartesi-rollups-machine-tool", + "replay", + "--template", + NODE_TEMPLATE_PATH, + "--application", + application, + "--to-epoch", + "108", + "--store", + "/tmp/s", + ]); + }); +}); + +describe("proveAccountsDriveArgs", () => { + it("should pass the accounts drive layout in hex", () => { + expect( + proveAccountsDriveArgs({ + account, + driveConfig: { + accountsDriveStartIndex: 0x300n, + log2LeavesPerAccount: 0n, + log2MaxNumOfAccounts: 17n, + }, + outDir: "/tmp/out", + snapshot: "/tmp/s", + }), + ).toEqual([ + "cartesi-rollups-machine-tool", + "prove", + "accounts-drive", + "--snapshot", + "/tmp/s", + "--accounts-drive-start-index", + "0x300", + "--log2-max-num-of-accounts", + "0x11", + "--log2-leaves-per-account", + "0x0", + "--account", + account, + "--out-drive-root-proof", + "/tmp/out/drive-root-proof.json", + "--out-withdraw-proof", + "/tmp/out/withdraw-proof.json", + ]); + }); +}); + +describe("parseReplaySummary", () => { + const summary = { + processed_inputs: 3, + last_input_index: "0x2", + machine_root: hash(1), + store: "/tmp/s", + }; + + it("should parse the summary", () => { + expect(parseReplaySummary(JSON.stringify(summary))).toEqual({ + machineRoot: hash(1), + processedInputs: 3, + store: "/tmp/s", + }); + }); + + it("should read the summary from the last line", () => { + expect( + parseReplaySummary(`log line\n${JSON.stringify(summary)}\n`) + .machineRoot, + ).toBe(hash(1)); + }); + + it("should reject a missing machine root", () => { + expect(() => + parseReplaySummary( + JSON.stringify({ ...summary, machine_root: undefined }), + ), + ).toThrow("machine_root"); + }); + + it("should reject a machine root that is not a hash", () => { + expect(() => + parseReplaySummary( + JSON.stringify({ ...summary, machine_root: "0x1234" }), + ), + ).toThrow("machine_root"); + }); + + it("should reject malformed output", () => { + expect(() => parseReplaySummary("not json")).toThrow(); + }); +}); + +describe("parseProveSummary", () => { + const summary = { + account, + account_index: "0x2a", + accounts_drive_merkle_root: hash(2), + machine_root: hash(1), + drive_root_proof_file: "/tmp/out/drive-root-proof.json", + withdraw_proof_file: "/tmp/out/withdraw-proof.json", + }; + + it("should parse the summary", () => { + expect(parseProveSummary(JSON.stringify(summary))).toEqual({ + accountIndex: 42n, + accountsDriveMerkleRoot: hash(2), + driveRootProofFile: "/tmp/out/drive-root-proof.json", + machineRoot: hash(1), + withdrawProofFile: "/tmp/out/withdraw-proof.json", + }); + }); + + it("should reject a missing withdraw proof file", () => { + expect(() => + parseProveSummary( + JSON.stringify({ ...summary, withdraw_proof_file: undefined }), + ), + ).toThrow("withdraw_proof_file"); + }); +}); diff --git a/apps/cli/tests/unit/exec/node-container.test.ts b/apps/cli/tests/unit/exec/node-container.test.ts new file mode 100644 index 00000000..7ca5e3bf --- /dev/null +++ b/apps/cli/tests/unit/exec/node-container.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from "bun:test"; +import { execa } from "execa"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + NODE_SIGNER_SCRIPT, + nodeExecArgs, + parseNodeSigner, + transactionArgs, +} from "../../../src/exec/node-container"; + +describe("transactionArgs", () => { + it("should forward nothing by default", () => { + expect(transactionArgs({})).toEqual([]); + }); + + it("should forward each option that is set", () => { + expect( + transactionArgs({ + json: true, + wait: false, + waitTimeout: "5m", + yes: true, + }), + ).toEqual(["--yes", "--json", "--no-wait", "--wait-timeout", "5m"]); + }); + + it("should not forward --no-wait when waiting", () => { + expect(transactionArgs({ wait: true })).toEqual([]); + }); +}); + +describe("nodeExecArgs", () => { + it("should forward environment variables by name only", () => { + const args = nodeExecArgs({ + command: ["true"], + env: { + CARTESI_AUTH_KIND: "mnemonic", + CARTESI_AUTH_MNEMONIC: "a b c", + }, + interactive: false, + projectName: "dapp", + }); + expect(args).toEqual([ + "compose", + "--project-name", + "dapp", + "exec", + "-T", + "-e", + "CARTESI_AUTH_KIND", + "-e", + "CARTESI_AUTH_MNEMONIC", + "rollups_node", + "true", + ]); + expect(args.join(" ")).not.toContain("a b c"); + }); + + it("should run the command in the working directory", () => { + expect( + nodeExecArgs({ + command: ["true"], + interactive: true, + projectName: "dapp", + workdir: "/tmp/work", + }), + ).toEqual([ + "compose", + "--project-name", + "dapp", + "exec", + "--workdir", + "/tmp/work", + "rollups_node", + "true", + ]); + }); + + it("should exec the command in the rollups node service", () => { + expect( + nodeExecArgs({ + command: ["cartesi-rollups-cli", "app", "list"], + interactive: true, + projectName: "dapp", + }), + ).toEqual([ + "compose", + "--project-name", + "dapp", + "exec", + "rollups_node", + "cartesi-rollups-cli", + "app", + "list", + ]); + }); + + it("should disable the TTY when not interactive", () => { + expect( + nodeExecArgs({ + command: ["true"], + interactive: false, + projectName: "dapp", + }), + ).toEqual([ + "compose", + "--project-name", + "dapp", + "exec", + "-T", + "rollups_node", + "true", + ]); + }); + + it("should select the signer account index", () => { + expect( + nodeExecArgs({ + accountIndex: 1, + command: ["true"], + interactive: true, + projectName: "dapp", + }), + ).toEqual([ + "compose", + "--project-name", + "dapp", + "exec", + "-e", + "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=1", + "rollups_node", + "true", + ]); + }); +}); + +describe("parseNodeSigner", () => { + it("should parse a mnemonic signer", () => { + expect(parseNodeSigner("mnemonic\ntest test junk\n")).toEqual({ + kind: "mnemonic", + mnemonic: "test test junk", + }); + }); + + it("should parse a mnemonic file signer as a mnemonic", () => { + expect(parseNodeSigner("mnemonic_file\ntest test junk")).toEqual({ + kind: "mnemonic", + mnemonic: "test test junk", + }); + }); + + it("should parse private key signers without their key", () => { + expect(parseNodeSigner("private_key\n")).toEqual({ + kind: "private_key", + }); + expect(parseNodeSigner("private_key_file\n")).toEqual({ + kind: "private_key", + }); + }); + + it("should reject a mnemonic signer without a mnemonic", () => { + expect(() => parseNodeSigner("mnemonic\n")).toThrow("no mnemonic"); + }); + + it("should reject unsupported signer kinds", () => { + expect(() => parseNodeSigner("aws\n")).toThrow("aws"); + expect(() => parseNodeSigner("ledger\n")).toThrow("ledger"); + }); +}); + +describe("NODE_SIGNER_SCRIPT", () => { + const run = async (env: Record) => + parseNodeSigner( + ( + await execa("sh", ["-c", NODE_SIGNER_SCRIPT], { + env, + extendEnv: false, + }) + ).stdout, + ); + + it("should default to an inline mnemonic", async () => { + expect(await run({ CARTESI_AUTH_MNEMONIC: "a b c" })).toEqual({ + kind: "mnemonic", + mnemonic: "a b c", + }); + }); + + it("should read the mnemonic file", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "signer-")); + const file = path.join(dir, "mnemonic"); + fs.writeFileSync(file, "x y z\n"); + try { + expect( + await run({ + CARTESI_AUTH_KIND: "mnemonic_file", + CARTESI_AUTH_MNEMONIC_FILE: file, + }), + ).toEqual({ kind: "mnemonic", mnemonic: "x y z" }); + } finally { + fs.rmSync(dir, { recursive: true }); + } + }); + + it("should never print a private key", async () => { + const { stdout } = await execa("sh", ["-c", NODE_SIGNER_SCRIPT], { + env: { + CARTESI_AUTH_KIND: "private_key", + CARTESI_AUTH_PRIVATE_KEY: "0xsecret", + }, + extendEnv: false, + }); + expect(stdout).not.toContain("0xsecret"); + expect(parseNodeSigner(stdout)).toEqual({ kind: "private_key" }); + }); +}); diff --git a/apps/cli/tests/unit/exec/rollups.test.ts b/apps/cli/tests/unit/exec/rollups.test.ts index 82f968a8..e9a153af 100644 --- a/apps/cli/tests/unit/exec/rollups.test.ts +++ b/apps/cli/tests/unit/exec/rollups.test.ts @@ -1,21 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { execa } from "execa"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { toHex } from "viem"; import { buildDeployApplicationArgs, - NODE_SIGNER_SCRIPT, - NODE_TEMPLATE_PATH, - nodeExecArgs, parseApplications, - parseNodeSigner, - parseProveSummary, - parseReplaySummary, - proveAccountsDriveArgs, - replayArgs, - transactionArgs, } from "../../../src/exec/rollups"; /** A single entry as `cartesi-rollups-cli app list` prints it. */ @@ -184,341 +170,3 @@ describe("buildDeployApplicationArgs", () => { expect(args.at(-1)).toBe("--json"); }); }); - -describe("transactionArgs", () => { - it("should forward nothing by default", () => { - expect(transactionArgs({})).toEqual([]); - }); - - it("should forward each option that is set", () => { - expect( - transactionArgs({ - json: true, - wait: false, - waitTimeout: "5m", - yes: true, - }), - ).toEqual(["--yes", "--json", "--no-wait", "--wait-timeout", "5m"]); - }); - - it("should not forward --no-wait when waiting", () => { - expect(transactionArgs({ wait: true })).toEqual([]); - }); -}); - -describe("nodeExecArgs", () => { - it("should forward environment variables by name only", () => { - const args = nodeExecArgs({ - command: ["true"], - env: { - CARTESI_AUTH_KIND: "mnemonic", - CARTESI_AUTH_MNEMONIC: "a b c", - }, - interactive: false, - projectName: "dapp", - }); - expect(args).toEqual([ - "compose", - "--project-name", - "dapp", - "exec", - "-T", - "-e", - "CARTESI_AUTH_KIND", - "-e", - "CARTESI_AUTH_MNEMONIC", - "rollups_node", - "true", - ]); - expect(args.join(" ")).not.toContain("a b c"); - }); - - it("should run the command in the working directory", () => { - expect( - nodeExecArgs({ - command: ["true"], - interactive: true, - projectName: "dapp", - workdir: "/tmp/work", - }), - ).toEqual([ - "compose", - "--project-name", - "dapp", - "exec", - "--workdir", - "/tmp/work", - "rollups_node", - "true", - ]); - }); - - it("should exec the command in the rollups node service", () => { - expect( - nodeExecArgs({ - command: ["cartesi-rollups-cli", "app", "list"], - interactive: true, - projectName: "dapp", - }), - ).toEqual([ - "compose", - "--project-name", - "dapp", - "exec", - "rollups_node", - "cartesi-rollups-cli", - "app", - "list", - ]); - }); - - it("should disable the TTY when not interactive", () => { - expect( - nodeExecArgs({ - command: ["true"], - interactive: false, - projectName: "dapp", - }), - ).toEqual([ - "compose", - "--project-name", - "dapp", - "exec", - "-T", - "rollups_node", - "true", - ]); - }); - - it("should select the signer account index", () => { - expect( - nodeExecArgs({ - accountIndex: 1, - command: ["true"], - interactive: true, - projectName: "dapp", - }), - ).toEqual([ - "compose", - "--project-name", - "dapp", - "exec", - "-e", - "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=1", - "rollups_node", - "true", - ]); - }); -}); - -const application = "0x1234567890abcdef1234567890abcdef12345678"; -const account = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; -const hash = (n: number) => toHex(n, { size: 32 }); - -describe("replayArgs", () => { - it("should replay the published template up to the epoch", () => { - expect( - replayArgs({ application, epochIndex: 108n, store: "/tmp/s" }), - ).toEqual([ - "cartesi-rollups-machine-tool", - "replay", - "--template", - NODE_TEMPLATE_PATH, - "--application", - application, - "--to-epoch", - "108", - "--store", - "/tmp/s", - ]); - }); -}); - -describe("proveAccountsDriveArgs", () => { - it("should pass the accounts drive layout in hex", () => { - expect( - proveAccountsDriveArgs({ - account, - driveConfig: { - accountsDriveStartIndex: 0x300n, - log2LeavesPerAccount: 0n, - log2MaxNumOfAccounts: 17n, - }, - outDir: "/tmp/out", - snapshot: "/tmp/s", - }), - ).toEqual([ - "cartesi-rollups-machine-tool", - "prove", - "accounts-drive", - "--snapshot", - "/tmp/s", - "--accounts-drive-start-index", - "0x300", - "--log2-max-num-of-accounts", - "0x11", - "--log2-leaves-per-account", - "0x0", - "--account", - account, - "--out-drive-root-proof", - "/tmp/out/drive-root-proof.json", - "--out-withdraw-proof", - "/tmp/out/withdraw-proof.json", - ]); - }); -}); - -describe("parseReplaySummary", () => { - const summary = { - processed_inputs: 3, - last_input_index: "0x2", - machine_root: hash(1), - store: "/tmp/s", - }; - - it("should parse the summary", () => { - expect(parseReplaySummary(JSON.stringify(summary))).toEqual({ - machineRoot: hash(1), - processedInputs: 3, - store: "/tmp/s", - }); - }); - - it("should read the summary from the last line", () => { - expect( - parseReplaySummary(`log line\n${JSON.stringify(summary)}\n`) - .machineRoot, - ).toBe(hash(1)); - }); - - it("should reject a missing machine root", () => { - expect(() => - parseReplaySummary( - JSON.stringify({ ...summary, machine_root: undefined }), - ), - ).toThrow("machine_root"); - }); - - it("should reject a machine root that is not a hash", () => { - expect(() => - parseReplaySummary( - JSON.stringify({ ...summary, machine_root: "0x1234" }), - ), - ).toThrow("machine_root"); - }); - - it("should reject malformed output", () => { - expect(() => parseReplaySummary("not json")).toThrow(); - }); -}); - -describe("parseProveSummary", () => { - const summary = { - account, - account_index: "0x2a", - accounts_drive_merkle_root: hash(2), - machine_root: hash(1), - drive_root_proof_file: "/tmp/out/drive-root-proof.json", - withdraw_proof_file: "/tmp/out/withdraw-proof.json", - }; - - it("should parse the summary", () => { - expect(parseProveSummary(JSON.stringify(summary))).toEqual({ - accountIndex: 42n, - accountsDriveMerkleRoot: hash(2), - driveRootProofFile: "/tmp/out/drive-root-proof.json", - machineRoot: hash(1), - withdrawProofFile: "/tmp/out/withdraw-proof.json", - }); - }); - - it("should reject a missing withdraw proof file", () => { - expect(() => - parseProveSummary( - JSON.stringify({ ...summary, withdraw_proof_file: undefined }), - ), - ).toThrow("withdraw_proof_file"); - }); -}); - -describe("parseNodeSigner", () => { - it("should parse a mnemonic signer", () => { - expect(parseNodeSigner("mnemonic\ntest test junk\n")).toEqual({ - kind: "mnemonic", - mnemonic: "test test junk", - }); - }); - - it("should parse a mnemonic file signer as a mnemonic", () => { - expect(parseNodeSigner("mnemonic_file\ntest test junk")).toEqual({ - kind: "mnemonic", - mnemonic: "test test junk", - }); - }); - - it("should parse private key signers without their key", () => { - expect(parseNodeSigner("private_key\n")).toEqual({ - kind: "private_key", - }); - expect(parseNodeSigner("private_key_file\n")).toEqual({ - kind: "private_key", - }); - }); - - it("should reject a mnemonic signer without a mnemonic", () => { - expect(() => parseNodeSigner("mnemonic\n")).toThrow("no mnemonic"); - }); - - it("should reject unsupported signer kinds", () => { - expect(() => parseNodeSigner("aws\n")).toThrow("aws"); - expect(() => parseNodeSigner("ledger\n")).toThrow("ledger"); - }); -}); - -describe("NODE_SIGNER_SCRIPT", () => { - const run = async (env: Record) => - parseNodeSigner( - ( - await execa("sh", ["-c", NODE_SIGNER_SCRIPT], { - env, - extendEnv: false, - }) - ).stdout, - ); - - it("should default to an inline mnemonic", async () => { - expect(await run({ CARTESI_AUTH_MNEMONIC: "a b c" })).toEqual({ - kind: "mnemonic", - mnemonic: "a b c", - }); - }); - - it("should read the mnemonic file", async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "signer-")); - const file = path.join(dir, "mnemonic"); - fs.writeFileSync(file, "x y z\n"); - try { - expect( - await run({ - CARTESI_AUTH_KIND: "mnemonic_file", - CARTESI_AUTH_MNEMONIC_FILE: file, - }), - ).toEqual({ kind: "mnemonic", mnemonic: "x y z" }); - } finally { - fs.rmSync(dir, { recursive: true }); - } - }); - - it("should never print a private key", async () => { - const { stdout } = await execa("sh", ["-c", NODE_SIGNER_SCRIPT], { - env: { - CARTESI_AUTH_KIND: "private_key", - CARTESI_AUTH_PRIVATE_KEY: "0xsecret", - }, - extendEnv: false, - }); - expect(stdout).not.toContain("0xsecret"); - expect(parseNodeSigner(stdout)).toEqual({ kind: "private_key" }); - }); -}); From 8c0379db767dd219aa11f3feab854b7ed1029bde Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 6 Oct 2026 17:45:19 +0100 Subject: [PATCH 16/25] refactor(cli): run the existing node commands through the container helper --- apps/cli/src/exec/node-container.ts | 15 ++- apps/cli/src/exec/rollups.ts | 101 +++++++----------- .../tests/unit/exec/node-container.test.ts | 21 ++++ 3 files changed, 72 insertions(+), 65 deletions(-) diff --git a/apps/cli/src/exec/node-container.ts b/apps/cli/src/exec/node-container.ts index 46ec1198..ce6a3209 100644 --- a/apps/cli/src/exec/node-container.ts +++ b/apps/cli/src/exec/node-container.ts @@ -40,10 +40,18 @@ export const nodeExecArgs = (options: { env?: Record; interactive: boolean; projectName: string; + service?: string; workdir?: string; }): string[] => { - const { accountIndex, command, env, interactive, projectName, workdir } = - options; + const { + accountIndex, + command, + env, + interactive, + projectName, + service = "rollups_node", + workdir, + } = options; const args = ["compose", "--project-name", projectName, "exec"]; if (!interactive) { args.push("-T"); @@ -59,7 +67,7 @@ export const nodeExecArgs = (options: { if (workdir) { args.push("--workdir", workdir); } - return [...args, "rollups_node", ...command]; + return [...args, service, ...command]; }; /** @@ -93,6 +101,7 @@ export const execNodeCommand = async (options: { command: string[]; input?: string; projectName: string; + service?: string; workdir?: string; }): Promise => { const { input, ...rest } = options; diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index 662ed12e..605d2688 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -30,6 +30,7 @@ import paymaster from "../compose/paymaster.js"; import proxy from "../compose/proxy.js"; import type { WithdrawalConfig } from "../config.js"; import type { ForkConfig } from "../types/chain.js"; +import { execNodeCommand } from "./node-container.js"; /** * Application status as reported by the node. `OK` is healthy and `FAILED` is a @@ -111,16 +112,10 @@ export const getDeployments = async ( options: ComposeParams, ): Promise => { try { - const { stdout } = await execa("docker", [ - "compose", - "--project-name", - options.projectName, - "exec", - "rollups_node", - "cartesi-rollups-cli", - "app", - "list", - ]); + const stdout = await execNodeCommand({ + command: ["cartesi-rollups-cli", "app", "list"], + projectName: options.projectName, + }); return parseApplications(stdout); } catch { return []; @@ -520,19 +515,17 @@ export const deployAuthority = async (options: { const { epochLength, projectName } = options; // deploy application - const { stdout } = await execa("docker", [ - "compose", - "--project-name", + const stdout = await execNodeCommand({ + command: [ + "cartesi-rollups-cli", + "deploy", + "authority", + "--epoch-length", + epochLength.toString(), + "--json", + ], projectName, - "exec", - "rollups_node", - "cartesi-rollups-cli", - "deploy", - "authority", - "--epoch-length", - epochLength.toString(), - "--json", - ]); + }); return getAddress(JSON.parse(stdout).address); }; @@ -610,17 +603,15 @@ export const deployApplication = async ( const deployArgs = buildDeployApplicationArgs(options); // deploy application - const { stdout } = await execa("docker", [ - "compose", - "--project-name", + const stdout = await execNodeCommand({ + command: [ + "cartesi-rollups-cli", + "deploy", + "application", + ...deployArgs, + ], projectName, - "exec", - "rollups_node", - "cartesi-rollups-cli", - "deploy", - "application", - ...deployArgs, - ]); + }); const deployment = stdout ? parseDeployment(JSON.parse(stdout)) : undefined; if (deployment) { @@ -642,18 +633,16 @@ export const removeApplication = async (options: { const { application, force, projectName } = options; // disable application first so we can remove it - await execa("docker", [ - "compose", - "--project-name", + await execNodeCommand({ + command: [ + "cartesi-rollups-cli", + "app", + "status", + application, + "disabled", + ], projectName, - "exec", - "rollups_node", - "cartesi-rollups-cli", - "app", - "status", - application, - "disabled", - ]); + }); const removeArgs = [application]; @@ -661,17 +650,10 @@ export const removeApplication = async (options: { removeArgs.push("--yes"); } - return execa("docker", [ - "compose", - "--project-name", + return execNodeCommand({ + command: ["cartesi-rollups-cli", "app", "remove", ...removeArgs], projectName, - "exec", - "rollups_node", - "cartesi-rollups-cli", - "app", - "remove", - ...removeArgs, - ]); + }); }; /** @@ -699,15 +681,10 @@ export const getProjectPort = async (options: { projectName: string }) => { */ export const getAnvilNodeInfo = async (options: { projectName: string }) => { const { projectName } = options; - const { stdout } = await execa("docker", [ - "compose", - "--project-name", + const stdout = await execNodeCommand({ + command: ["cast", "rpc", "anvil_nodeInfo"], projectName, - "exec", - "anvil", - "cast", - "rpc", - "anvil_nodeInfo", - ]); + service: "anvil", + }); return JSON.parse(stdout); }; diff --git a/apps/cli/tests/unit/exec/node-container.test.ts b/apps/cli/tests/unit/exec/node-container.test.ts index 7ca5e3bf..4dc4b0b7 100644 --- a/apps/cli/tests/unit/exec/node-container.test.ts +++ b/apps/cli/tests/unit/exec/node-container.test.ts @@ -32,6 +32,27 @@ describe("transactionArgs", () => { }); describe("nodeExecArgs", () => { + it("should exec in another service when given", () => { + expect( + nodeExecArgs({ + command: ["cast", "rpc", "anvil_nodeInfo"], + interactive: false, + projectName: "dapp", + service: "anvil", + }), + ).toEqual([ + "compose", + "--project-name", + "dapp", + "exec", + "-T", + "anvil", + "cast", + "rpc", + "anvil_nodeInfo", + ]); + }); + it("should forward environment variables by name only", () => { const args = nodeExecArgs({ command: ["true"], From 06ca5a7d9a9e4eeb5a913fd06e7aed65e8461cda Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 6 Oct 2026 17:56:57 +0100 Subject: [PATCH 17/25] refactor(cli): share the recovery command options --- apps/cli/src/commands/foreclose.ts | 119 ++++++++----------- apps/cli/src/commands/refund.ts | 129 +++++++++----------- apps/cli/src/commands/withdraw.ts | 183 +++++++++++++---------------- apps/cli/src/node.ts | 44 ++++++- 4 files changed, 228 insertions(+), 247 deletions(-) diff --git a/apps/cli/src/commands/foreclose.ts b/apps/cli/src/commands/foreclose.ts index 79c2ff92..dd040f51 100644 --- a/apps/cli/src/commands/foreclose.ts +++ b/apps/cli/src/commands/foreclose.ts @@ -1,4 +1,4 @@ -import { Command, Option } from "@commander-js/extra-typings"; +import { Command } from "@commander-js/extra-typings"; import chalk from "chalk"; import { type Address, @@ -22,7 +22,7 @@ import { } from "../exec/node-container.js"; import { getNodeClient, - parseAccountIndex, + addRecoveryOptions, resolveNodeApplication, } from "../node.js"; import { findMnemonicAccountIndex } from "../wallet.js"; @@ -164,78 +164,59 @@ export const resolveForecloseSigner = (options: { }; export const createForecloseCommand = () => { - return new Command("foreclose") - .description( - "Forecloses the application, so users can withdraw their funds", - ) - .addHelpText( - "after", - ` + return addRecoveryOptions( + new Command("foreclose") + .description( + "Forecloses the application, so users can withdraw their funds", + ) + .addHelpText( + "after", + ` Signs as the application guardian. By default it uses the rollups node signer, looking up the guardian in its mnemonic. For a guardian outside it, set the signer for this command only: CARTESI_AUTH_MNEMONIC="" cartesi foreclose CARTESI_AUTH_PRIVATE_KEY=<0x-private-key> cartesi foreclose Set CARTESI_AUTH_KIND to mnemonic or private_key when both are set.`, - ) - .configureHelp({ showGlobalOptions: true }) - .option("--application
", "application address") - .option( - "--project-name ", - "name of project (used by docker compose and cartesi-rollups-node)", - ) - .addOption( - new Option( - "--account-index ", - "index of the guardian in the mnemonic (default: looked up from the application guardian; ignored for private-key signers)", - ).argParser(parseAccountIndex), - ) - .option("-y, --yes", "skip the confirmation prompt") - .option("--json", "print the result as JSON") - .option( - "--no-wait", - "return after broadcast without waiting for the receipt", - ) - .option( - "--wait-timeout ", - "maximum time to wait for the receipt (e.g. 30s, 5m)", - ) - .action(async (options) => { - const projectName = getProjectName(options); - const application = await resolveNodeApplication({ - application: options.application, - projectName, - }); - - const client = await getNodeClient({ projectName }); - const { withdrawalConfig } = await client.getApplication({ - application, - }); - // the node signer is only needed without a signer for the command - const host = getHostSigner(); - const { accountIndex, env, warning } = resolveForecloseSigner({ - accountIndex: options.accountIndex, - guardian: withdrawalConfig.guardian, - host, - node: host ? undefined : await getNodeSigner({ projectName }), - }); - if (warning) { - console.warn(chalk.yellow(warning)); - } + ) + .configureHelp({ showGlobalOptions: true }), + "index of the guardian in the mnemonic (default: looked up from the application guardian; ignored for private-key signers)", + ).action(async (options) => { + const projectName = getProjectName(options); + const application = await resolveNodeApplication({ + application: options.application, + projectName, + }); - try { - await runNodeCommand({ - accountIndex, - command: [ - "cartesi-rollups-cli", - "foreclose", - application, - ...transactionArgs(options), - ], - env, - projectName, - }); - } catch (error: unknown) { - handleNodeCommandError(error); - } + const client = await getNodeClient({ projectName }); + const { withdrawalConfig } = await client.getApplication({ + application, }); + // the node signer is only needed without a signer for the command + const host = getHostSigner(); + const { accountIndex, env, warning } = resolveForecloseSigner({ + accountIndex: options.accountIndex, + guardian: withdrawalConfig.guardian, + host, + node: host ? undefined : await getNodeSigner({ projectName }), + }); + if (warning) { + console.warn(chalk.yellow(warning)); + } + + try { + await runNodeCommand({ + accountIndex, + command: [ + "cartesi-rollups-cli", + "foreclose", + application, + ...transactionArgs(options), + ], + env, + projectName, + }); + } catch (error: unknown) { + handleNodeCommandError(error); + } + }); }; diff --git a/apps/cli/src/commands/refund.ts b/apps/cli/src/commands/refund.ts index ee60d16b..2dcb1011 100644 --- a/apps/cli/src/commands/refund.ts +++ b/apps/cli/src/commands/refund.ts @@ -1,9 +1,5 @@ import type { CartesiPublicClient } from "@cartesi/client"; -import { - Command, - InvalidArgumentError, - Option, -} from "@commander-js/extra-typings"; +import { Command, InvalidArgumentError } from "@commander-js/extra-typings"; import chalk from "chalk"; import fs from "fs-extra"; import { type Address, type Hex, isHex } from "viem"; @@ -17,7 +13,7 @@ import { } from "../exec/node-container.js"; import { getNodeClient, - parseAccountIndex, + addRecoveryOptions, resolveNodeApplication, } from "../node.js"; @@ -67,78 +63,59 @@ export const getInputData = async (options: { }; export const createRefundCommand = () => { - return new Command("refund") - .description( - "Refunds a deposit that was not finalized before the application was foreclosed", - ) - .configureHelp({ showGlobalOptions: true }) - .argument( - "", - "index of the deposit input", - parseInputIndex, - ) - .option("--application
", "application address") - .option( - "--project-name ", - "name of project (used by docker compose and cartesi-rollups-node)", - ) - .option( - "--input-file ", - "file with the 0x-prefixed InputAdded.input bytes (default: read from the node)", - ) - .addOption( - new Option( - "--account-index ", - "index of the account in the node mnemonic paying for gas", - ).argParser(parseAccountIndex), - ) - .option("-y, --yes", "skip the confirmation prompt") - .option("--json", "print the result as JSON") - .option( - "--no-wait", - "return after broadcast without waiting for the receipt", - ) - .option( - "--wait-timeout ", - "maximum time to wait for the receipt (e.g. 30s, 5m)", - ) - .action(async (inputIndex, options) => { - const projectName = getProjectName(options); - const application = await resolveNodeApplication({ - application: options.application, - projectName, - }); + return addRecoveryOptions( + new Command("refund") + .description( + "Refunds a deposit that was not finalized before the application was foreclosed", + ) + .configureHelp({ showGlobalOptions: true }) + .argument( + "", + "index of the deposit input", + parseInputIndex, + ) + .option( + "--input-file ", + "file with the 0x-prefixed InputAdded.input bytes (default: read from the node)", + ), + "index of the account in the node mnemonic paying for gas", + ).action(async (inputIndex, options) => { + const projectName = getProjectName(options); + const application = await resolveNodeApplication({ + application: options.application, + projectName, + }); - const data = await getInputData({ - application, - inputFile: options.inputFile, - inputIndex, - projectName, - }); + const data = await getInputData({ + application, + inputFile: options.inputFile, + inputIndex, + projectName, + }); - // the node tool reads the input bytes from a file in its container - const path = await writeNodeTempFile({ - content: `${data}\n`, + // the node tool reads the input bytes from a file in its container + const path = await writeNodeTempFile({ + content: `${data}\n`, + projectName, + }); + try { + await runNodeCommand({ + accountIndex: options.accountIndex, + command: [ + "cartesi-rollups-cli", + "refund", + application, + inputIndex.toString(), + "--input-file", + path, + ...transactionArgs(options), + ], projectName, }); - try { - await runNodeCommand({ - accountIndex: options.accountIndex, - command: [ - "cartesi-rollups-cli", - "refund", - application, - inputIndex.toString(), - "--input-file", - path, - ...transactionArgs(options), - ], - projectName, - }); - } catch (error: unknown) { - handleNodeCommandError(error); - } finally { - await removeNodePath({ path, projectName }); - } - }); + } catch (error: unknown) { + handleNodeCommandError(error); + } finally { + await removeNodePath({ path, projectName }); + } + }); }; diff --git a/apps/cli/src/commands/withdraw.ts b/apps/cli/src/commands/withdraw.ts index a7f869b1..2f0b155f 100644 --- a/apps/cli/src/commands/withdraw.ts +++ b/apps/cli/src/commands/withdraw.ts @@ -27,7 +27,7 @@ import { } from "../exec/cartesi-rollups-machine-tool.js"; import { getNodeClient, - parseAccountIndex, + addRecoveryOptions, resolveNodeApplication, } from "../node.js"; import { addressInput } from "../prompts.js"; @@ -354,111 +354,92 @@ const proveAccount = async (options: { }; export const createWithdrawCommand = () => { - return new Command("withdraw") - .description( - "Withdraws the funds of an account from a foreclosed application", - ) - .configureHelp({ showGlobalOptions: true }) - .addOption( - new Option( - "--account
", - "account to withdraw the funds of", - ).argParser(parseAddress), - ) - .addOption( - new Option( - "--proof-file ", - "withdraw proof generated elsewhere, instead of generating it", - ).conflicts("account"), - ) - .option("--application
", "application address") - .option( - "--project-name ", - "name of project (used by docker compose and cartesi-rollups-node)", - ) - .addOption( - new Option( - "--account-index ", - "index of the account in the node mnemonic paying for gas", - ).argParser(parseAccountIndex), - ) - .option("-y, --yes", "skip the confirmation prompt") - .option("--json", "print the result as JSON") - .option( - "--no-wait", - "return after broadcast without waiting for the receipt", - ) - .option( - "--wait-timeout ", - "maximum time to wait for the receipt (e.g. 30s, 5m)", - ) - .action(async (options) => { - const { accountIndex, proofFile } = options; - const projectName = getProjectName(options); - const application = await resolveNodeApplication({ - application: options.application, - projectName, - }); - const client = await getNodeClient({ projectName }); + return addRecoveryOptions( + new Command("withdraw") + .description( + "Withdraws the funds of an account from a foreclosed application", + ) + .configureHelp({ showGlobalOptions: true }) + .addOption( + new Option( + "--account
", + "account to withdraw the funds of", + ).argParser(parseAddress), + ) + .addOption( + new Option( + "--proof-file ", + "withdraw proof generated elsewhere, instead of generating it", + ).conflicts("account"), + ), + "index of the account in the node mnemonic paying for gas", + ).action(async (options) => { + const { accountIndex, proofFile } = options; + const projectName = getProjectName(options); + const application = await resolveNodeApplication({ + application: options.application, + projectName, + }); + const client = await getNodeClient({ projectName }); - const { driveRootProven, finalizedMachineRoot, foreclosed } = - await readForeclosureState({ application, projectName }); - if (!foreclosed) { - throw new Error( - `Application ${chalk.cyan(application)} is not foreclosed, use ${chalk.cyan("cartesi foreclose")} first`, - ); - } + const { driveRootProven, finalizedMachineRoot, foreclosed } = + await readForeclosureState({ application, projectName }); + if (!foreclosed) { + throw new Error( + `Application ${chalk.cyan(application)} is not foreclosed, use ${chalk.cyan("cartesi foreclose")} first`, + ); + } - let withdrawProofFile: string; - let tmpProofFile: string | undefined; - try { - if (proofFile) { - if (!driveRootProven) { - throw new Error( - `The accounts drive root of ${application} isn't proven yet. ${chalk.cyan("--proof-file")} only carries the account proof; run ${chalk.cyan("cartesi withdraw --account
")} once so the CLI proves the drive root, then ${chalk.cyan("--proof-file")} will work`, - ); - } - tmpProofFile = await writeNodeTempFile({ - content: await fs.readFile(proofFile, "utf-8"), - projectName, - }); - withdrawProofFile = tmpProofFile; - } else { - const account = - options.account ?? - (await addressInput({ - message: "Account to withdraw the funds of", - })); - withdrawProofFile = await proveAccount({ - account, - accountIndex, - application, - client, - driveRootProven, - finalizedMachineRoot, - projectName, - tx: options, - }); + let withdrawProofFile: string; + let tmpProofFile: string | undefined; + try { + if (proofFile) { + if (!driveRootProven) { + throw new Error( + `The accounts drive root of ${application} isn't proven yet. ${chalk.cyan("--proof-file")} only carries the account proof; run ${chalk.cyan("cartesi withdraw --account
")} once so the CLI proves the drive root, then ${chalk.cyan("--proof-file")} will work`, + ); } - - await runNodeCommand({ + tmpProofFile = await writeNodeTempFile({ + content: await fs.readFile(proofFile, "utf-8"), + projectName, + }); + withdrawProofFile = tmpProofFile; + } else { + const account = + options.account ?? + (await addressInput({ + message: "Account to withdraw the funds of", + })); + withdrawProofFile = await proveAccount({ + account, accountIndex, - command: [ - "cartesi-rollups-cli", - "withdraw", - application, - "--proof-file", - withdrawProofFile, - ...transactionArgs(options), - ], + application, + client, + driveRootProven, + finalizedMachineRoot, projectName, + tx: options, }); - } catch (error: unknown) { - handleNodeCommandError(error); - } finally { - if (tmpProofFile) { - await removeNodePath({ path: tmpProofFile, projectName }); - } } - }); + + await runNodeCommand({ + accountIndex, + command: [ + "cartesi-rollups-cli", + "withdraw", + application, + "--proof-file", + withdrawProofFile, + ...transactionArgs(options), + ], + projectName, + }); + } catch (error: unknown) { + handleNodeCommandError(error); + } finally { + if (tmpProofFile) { + await removeNodePath({ path: tmpProofFile, projectName }); + } + } + }); }; diff --git a/apps/cli/src/node.ts b/apps/cli/src/node.ts index 999b7de1..8813eacc 100644 --- a/apps/cli/src/node.ts +++ b/apps/cli/src/node.ts @@ -2,7 +2,12 @@ import { type CartesiPublicClient, createCartesiPublicClient, } from "@cartesi/client"; -import { InvalidArgumentError } from "@commander-js/extra-typings"; +import { + type Command, + InvalidArgumentError, + Option, + type OptionValues, +} from "@commander-js/extra-typings"; import chalk from "chalk"; import { type Address, getAddress, http, isAddress } from "viem"; import { getServiceState } from "./base.js"; @@ -77,3 +82,40 @@ export const parseAccountIndex = (value: string): number => { } return index; }; + +/** + * Add the options shared by the fund recovery commands: the application and + * project, the signer account index, and the transaction flags forwarded to + * cartesi-rollups-cli + * @param accountIndexDescription what the account index selects for this command + */ +export const addRecoveryOptions = < + Args extends unknown[], + Opts extends OptionValues, + GlobalOpts extends OptionValues, +>( + command: Command, + accountIndexDescription: string, +) => + command + .option("--application
", "application address") + .option( + "--project-name ", + "name of project (used by docker compose and cartesi-rollups-node)", + ) + .addOption( + new Option( + "--account-index ", + accountIndexDescription, + ).argParser(parseAccountIndex), + ) + .option("-y, --yes", "skip the confirmation prompt") + .option("--json", "print the result as JSON") + .option( + "--no-wait", + "return after broadcast without waiting for the receipt", + ) + .option( + "--wait-timeout ", + "maximum time to wait for the receipt (e.g. 30s, 5m)", + ); From fd2c835c7ecb4986adacfd6f23b0ea961330e0d6 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 6 Oct 2026 18:14:06 +0100 Subject: [PATCH 18/25] refactor(cli): tidy the withdraw steps --- apps/cli/src/commands/refund.ts | 8 ++-- apps/cli/src/commands/withdraw.ts | 38 ++++++++++--------- apps/cli/src/node.ts | 25 +++++++++--- apps/cli/tests/unit/commands/refund.test.ts | 4 -- apps/cli/tests/unit/commands/withdraw.test.ts | 19 +++++++--- apps/cli/tests/unit/node.test.ts | 12 +++--- 6 files changed, 64 insertions(+), 42 deletions(-) diff --git a/apps/cli/src/commands/refund.ts b/apps/cli/src/commands/refund.ts index 2dcb1011..f7ecdd14 100644 --- a/apps/cli/src/commands/refund.ts +++ b/apps/cli/src/commands/refund.ts @@ -33,12 +33,11 @@ export const parseInputIndex = (value: string): bigint => { */ export const getInputData = async (options: { application: Address; - client?: Pick; + client: Pick; inputFile?: string; inputIndex: bigint; - projectName: string; }): Promise => { - const { application, inputFile, inputIndex, projectName } = options; + const { application, client, inputFile, inputIndex } = options; if (inputFile) { const data = (await fs.readFile(inputFile, "utf-8")).trim(); @@ -50,7 +49,6 @@ export const getInputData = async (options: { return data; } - const client = options.client ?? (await getNodeClient({ projectName })); try { const input = await client.getInput({ application, inputIndex }); return input.rawData; @@ -88,9 +86,9 @@ export const createRefundCommand = () => { const data = await getInputData({ application, + client: await getNodeClient({ projectName }), inputFile: options.inputFile, inputIndex, - projectName, }); // the node tool reads the input bytes from a file in its container diff --git a/apps/cli/src/commands/withdraw.ts b/apps/cli/src/commands/withdraw.ts index 2f0b155f..56a3fc28 100644 --- a/apps/cli/src/commands/withdraw.ts +++ b/apps/cli/src/commands/withdraw.ts @@ -26,12 +26,12 @@ import { replayMachine, } from "../exec/cartesi-rollups-machine-tool.js"; import { - getNodeClient, addRecoveryOptions, + getAnvilClient, + getNodeClient, resolveNodeApplication, } from "../node.js"; import { addressInput } from "../prompts.js"; -import { connect } from "../wallet.js"; /** * Directory inside the rollups node container where replayed machines and @@ -46,6 +46,12 @@ const WORK_DIR = "/tmp/cartesi-withdraw"; export const replayStorePath = (application: Address, epochIndex: bigint) => `${WORK_DIR}/${application}/epoch-${epochIndex}`; +/** + * Directory of the proofs generated for an account of an application + */ +export const proofStorePath = (application: Address, account: Address) => + `${WORK_DIR}/${application}/${account}`; + /** * Operations on the rollups node container used to replay and prove, so tests * can run the steps without one @@ -87,7 +93,7 @@ const readForeclosureState = async (options: { projectName: string; }) => { const { application, projectName } = options; - const client = await connect({ projectName }); + const client = await getAnvilClient({ projectName }); const [foreclosed, [driveRootProven], validator] = await Promise.all([ client.readContract({ abi: iApplicationAbi, @@ -169,7 +175,7 @@ export const replayLastFinalizedEpoch = async (options: { io?: WithdrawIo; projectName: string; retry?: RetryOptions; -}): Promise<{ machineHash: Hash; store: string }> => { +}): Promise<{ store: string }> => { const { application, client, @@ -178,8 +184,6 @@ export const replayLastFinalizedEpoch = async (options: { projectName, retry = FINALIZED_EPOCH_RETRY, } = options; - const machineHash = finalizedMachineRoot; - if (isSameHash(finalizedMachineRoot, zeroHash)) { throw new Error("No finalized epoch, there are no funds to withdraw"); } @@ -203,7 +207,7 @@ export const replayLastFinalizedEpoch = async (options: { progress.succeed( `Using the machine replayed up to epoch ${chalk.cyan(epochIndex)}`, ); - return { machineHash, store }; + return { store }; } // replay into a temporary path, so an interrupted replay is never reused @@ -217,9 +221,9 @@ export const replayLastFinalizedEpoch = async (options: { projectName, store: tmp, }); - if (!isSameHash(replay.machineRoot, machineHash)) { + if (!isSameHash(replay.machineRoot, finalizedMachineRoot)) { throw new Error( - `Replayed machine root ${replay.machineRoot} does not match the machine hash ${machineHash} of epoch ${epochIndex}`, + `Replayed machine root ${replay.machineRoot} does not match the finalized machine root ${finalizedMachineRoot} of epoch ${epochIndex}`, ); } await io.execNodeCommand({ command: ["mv", tmp, store], projectName }); @@ -231,7 +235,7 @@ export const replayLastFinalizedEpoch = async (options: { await io.removeNodePath({ path: tmp, projectName }); throw error; } - return { machineHash, store }; + return { store }; }; /** @@ -244,7 +248,7 @@ export const generateAccountProofs = async (options: { application: Address; driveConfig: AccountsDriveConfig; io?: WithdrawIo; - machineHash: Hash; + finalizedMachineRoot: Hash; projectName: string; store: string; }) => { @@ -252,8 +256,8 @@ export const generateAccountProofs = async (options: { account, application, driveConfig, + finalizedMachineRoot, io = defaultIo, - machineHash, projectName, store, } = options; @@ -264,7 +268,7 @@ export const generateAccountProofs = async (options: { .proveAccountsDrive({ account, driveConfig, - outDir: `${WORK_DIR}/${application}/${account}`, + outDir: proofStorePath(application, account), projectName, snapshot: store, }) @@ -272,13 +276,13 @@ export const generateAccountProofs = async (options: { progress.fail(`Failed to generate the proofs of ${account}`); throw error; }); - if (!isSameHash(proof.machineRoot, machineHash)) { + if (!isSameHash(proof.machineRoot, finalizedMachineRoot)) { progress.fail(); // a cached snapshot that doesn't prove the finalized root would fail // every retry, so drop it and let the next run replay await io.removeNodePath({ path: store, projectName }); throw new Error( - `Proof machine root ${proof.machineRoot} does not match the finalized machine root ${machineHash}. Removed the cached snapshot; run the command again to replay it`, + `Proof machine root ${proof.machineRoot} does not match the finalized machine root ${finalizedMachineRoot}. Removed the cached snapshot; run the command again to replay it`, ); } progress.succeed( @@ -316,7 +320,7 @@ const proveAccount = async (options: { application, }); - const { machineHash, store } = await replayLastFinalizedEpoch({ + const { store } = await replayLastFinalizedEpoch({ application, client, finalizedMachineRoot, @@ -327,7 +331,7 @@ const proveAccount = async (options: { account, application, driveConfig: withdrawalConfig, - machineHash, + finalizedMachineRoot, projectName, store, }); diff --git a/apps/cli/src/node.ts b/apps/cli/src/node.ts index 8813eacc..73f2deca 100644 --- a/apps/cli/src/node.ts +++ b/apps/cli/src/node.ts @@ -9,7 +9,13 @@ import { type OptionValues, } from "@commander-js/extra-typings"; import chalk from "chalk"; -import { type Address, getAddress, http, isAddress } from "viem"; +import { + type Address, + createPublicClient, + getAddress, + http, + isAddress, +} from "viem"; import { getServiceState } from "./base.js"; import { getApplicationAddress, getProjectPort } from "./exec/rollups.js"; @@ -27,6 +33,15 @@ export const getNodeClient = async (options: { }); }; +/** + * Create a read-only client for the anvil of the local environment + * @param options projectName + */ +export const getAnvilClient = async (options: { projectName: string }) => { + const host = await getProjectPort(options); + return createPublicClient({ transport: http(`http://${host}/anvil`) }); +}; + /** * Resolve the application a command acts on, making sure the local * environment is running @@ -34,7 +49,7 @@ export const getNodeClient = async (options: { */ export const resolveNodeApplication = async (options: { application?: string; - lookup?: { + io?: { getApplicationAddress: typeof getApplicationAddress; getServiceState: typeof getServiceState; }; @@ -42,11 +57,11 @@ export const resolveNodeApplication = async (options: { }): Promise
=> { const { application, - lookup = { getApplicationAddress, getServiceState }, + io = { getApplicationAddress, getServiceState }, projectName, } = options; - const state = await lookup.getServiceState({ + const state = await io.getServiceState({ projectName, service: "rollups_node", }); @@ -63,7 +78,7 @@ export const resolveNodeApplication = async (options: { return getAddress(application); } - const address = await lookup.getApplicationAddress({ projectName }); + const address = await io.getApplicationAddress({ projectName }); if (!address) { throw new Error( `No application deployed for the current machine, use ${chalk.cyan("--application")}`, diff --git a/apps/cli/tests/unit/commands/refund.test.ts b/apps/cli/tests/unit/commands/refund.test.ts index 5443de65..0048f8dd 100644 --- a/apps/cli/tests/unit/commands/refund.test.ts +++ b/apps/cli/tests/unit/commands/refund.test.ts @@ -49,7 +49,6 @@ describe("getInputData", () => { client: unusedClient, inputFile: file("valid.hex", " 0xdeadbeef\n"), inputIndex: 2n, - projectName: "dapp", }), ).toBe("0xdeadbeef"); }); @@ -62,7 +61,6 @@ describe("getInputData", () => { client: unusedClient, inputFile: file("invalid.hex", content), inputIndex: 2n, - projectName: "dapp", }), ).rejects.toThrow("must contain the 0x-prefixed hexadecimal"); } @@ -77,7 +75,6 @@ describe("getInputData", () => { application, client: client as never, inputIndex: 2n, - projectName: "dapp", }), ).toBe("0xcafe"); }); @@ -93,7 +90,6 @@ describe("getInputData", () => { application, client: client as never, inputIndex: 99n, - projectName: "dapp", }), ).rejects.toThrow(/Input 99 not found in the node, use .*--input-file/); }); diff --git a/apps/cli/tests/unit/commands/withdraw.test.ts b/apps/cli/tests/unit/commands/withdraw.test.ts index 2a9bcbfd..0505d61c 100644 --- a/apps/cli/tests/unit/commands/withdraw.test.ts +++ b/apps/cli/tests/unit/commands/withdraw.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { type Hash, toHex, zeroHash } from "viem"; import { generateAccountProofs, + proofStorePath, replayLastFinalizedEpoch, replayStorePath, type WithdrawIo, @@ -67,6 +68,14 @@ const recordingIo = (options: { return { calls, io }; }; +describe("store paths", () => { + it("should key the proofs by application and account", () => { + expect(proofStorePath(application, account)).toBe( + `/tmp/cartesi-withdraw/${application}/${account}`, + ); + }); +}); + describe("replayStorePath", () => { it("should key the snapshot by application and epoch", () => { expect(replayStorePath(application, 7n)).toBe( @@ -102,7 +111,7 @@ describe("replayLastFinalizedEpoch", () => { io, projectName: "dapp", }), - ).toEqual({ machineHash: root, store }); + ).toEqual({ store }); expect(calls).toEqual([`exists ${store}`]); }); @@ -116,7 +125,7 @@ describe("replayLastFinalizedEpoch", () => { io, projectName: "dapp", }), - ).toEqual({ machineHash: root, store }); + ).toEqual({ store }); expect(calls).toEqual([ `exists ${store}`, `rm ${store}.tmp`, @@ -155,7 +164,7 @@ describe("replayLastFinalizedEpoch", () => { projectName: "dapp", retry: fastRetry, }), - ).toEqual({ machineHash: root, store }); + ).toEqual({ store }); expect(calls).toContain("replay 1"); }); @@ -190,7 +199,7 @@ describe("generateAccountProofs", () => { application, driveConfig, io, - machineHash: root, + finalizedMachineRoot: root, projectName: "dapp", store, }); @@ -206,7 +215,7 @@ describe("generateAccountProofs", () => { application, driveConfig, io, - machineHash: root, + finalizedMachineRoot: root, projectName: "dapp", store, }), diff --git a/apps/cli/tests/unit/node.test.ts b/apps/cli/tests/unit/node.test.ts index f085435c..24b69ccc 100644 --- a/apps/cli/tests/unit/node.test.ts +++ b/apps/cli/tests/unit/node.test.ts @@ -16,7 +16,7 @@ describe("parseAccountIndex", () => { describe("resolveNodeApplication", () => { const deployed = "0x1234567890abcdef1234567890abcdef12345678"; - const lookup = (state: string | undefined, address?: string) => ({ + const io = (state: string | undefined, address?: string) => ({ getApplicationAddress: async () => address as never, getServiceState: async () => state, }); @@ -24,7 +24,7 @@ describe("resolveNodeApplication", () => { it("should tell to start the environment when the node isn't running", async () => { await expect( resolveNodeApplication({ - lookup: lookup("exited", deployed), + io: io("exited", deployed), projectName: "dapp", }), ).rejects.toThrow(/is not running, use .*cartesi run/); @@ -34,7 +34,7 @@ describe("resolveNodeApplication", () => { expect( await resolveNodeApplication({ application: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", - lookup: lookup("running", deployed), + io: io("running", deployed), projectName: "dapp", }), ).toBe("0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD"); @@ -44,7 +44,7 @@ describe("resolveNodeApplication", () => { await expect( resolveNodeApplication({ application: "0x123", - lookup: lookup("running", deployed), + io: io("running", deployed), projectName: "dapp", }), ).rejects.toThrow("Invalid application address 0x123"); @@ -53,7 +53,7 @@ describe("resolveNodeApplication", () => { it("should use the application deployed for the current machine", async () => { expect( await resolveNodeApplication({ - lookup: lookup("running", deployed), + io: io("running", deployed), projectName: "dapp", }), ).toBe(deployed); @@ -62,7 +62,7 @@ describe("resolveNodeApplication", () => { it("should tell to pass --application when none is deployed", async () => { await expect( resolveNodeApplication({ - lookup: lookup("running"), + io: io("running"), projectName: "dapp", }), ).rejects.toThrow(/No application deployed .*--application/); From be6c794c9909b81fd9cfef04a85657116ff6ce39 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 6 Oct 2026 18:26:57 +0100 Subject: [PATCH 19/25] refactor(cli): keep the signer helpers together --- apps/cli/src/commands/foreclose.ts | 90 ++----------------- apps/cli/src/exec/node-container.ts | 71 +++++++++++++++ .../cli/tests/unit/commands/foreclose.test.ts | 59 +----------- .../tests/unit/exec/node-container.test.ts | 73 +++++++++++++++ 4 files changed, 154 insertions(+), 139 deletions(-) diff --git a/apps/cli/src/commands/foreclose.ts b/apps/cli/src/commands/foreclose.ts index dd040f51..1a7eb043 100644 --- a/apps/cli/src/commands/foreclose.ts +++ b/apps/cli/src/commands/foreclose.ts @@ -1,21 +1,14 @@ import { Command } from "@commander-js/extra-typings"; import chalk from "chalk"; -import { - type Address, - type Hex, - isAddressEqual, - isHex, - zeroAddress, -} from "viem"; +import { type Address, isAddressEqual, zeroAddress } from "viem"; import { privateKeyToAccount } from "viem/accounts"; +import { getCartesiEnvironmentVariables, getProjectName } from "../base.js"; import { - type CartesiEnvironmentVariables, - getCartesiEnvironmentVariables, - getProjectName, -} from "../base.js"; -import { + getHostSigner, getNodeSigner, handleNodeCommandError, + type HostSigner, + hostSignerEnv, type NodeSigner, runNodeCommand, transactionArgs, @@ -27,61 +20,6 @@ import { } from "../node.js"; import { findMnemonicAccountIndex } from "../wallet.js"; -/** - * Signer given for a single foreclose, in the environment of the shell that - * runs it, so the guardian can be an account the node doesn't sign with - */ -export type HostSigner = - | { kind: "mnemonic"; mnemonic: string } - | { kind: "private_key"; privateKey: Hex }; - -/** - * Read the per-command signer from the CARTESI_AUTH_* environment variables - * of the host, following cartesi-rollups-cli: CARTESI_AUTH_KIND picks the - * kind, otherwise the variable that is set does - * @returns the signer, or undefined when none is set - */ -export const getHostSigner = ( - env: CartesiEnvironmentVariables = getCartesiEnvironmentVariables(), -): HostSigner | undefined => { - const { - CARTESI_AUTH_KIND: kind, - CARTESI_AUTH_MNEMONIC: mnemonic, - CARTESI_AUTH_PRIVATE_KEY: privateKey, - } = env; - - if (!kind && !mnemonic && !privateKey) { - return undefined; - } - if (!kind && mnemonic && privateKey) { - throw new Error( - "Both CARTESI_AUTH_MNEMONIC and CARTESI_AUTH_PRIVATE_KEY are set, set CARTESI_AUTH_KIND to mnemonic or private_key to choose", - ); - } - - const resolvedKind = kind ?? (mnemonic ? "mnemonic" : "private_key"); - switch (resolvedKind) { - case "mnemonic": - if (!mnemonic) { - throw new Error( - "CARTESI_AUTH_KIND is mnemonic, but CARTESI_AUTH_MNEMONIC is not set", - ); - } - return { kind: "mnemonic", mnemonic }; - case "private_key": - if (!privateKey || !isHex(privateKey)) { - throw new Error( - "CARTESI_AUTH_KIND is private_key, but CARTESI_AUTH_PRIVATE_KEY is not a 0x-prefixed private key", - ); - } - return { kind: "private_key", privateKey }; - default: - throw new Error( - `CARTESI_AUTH_KIND ${resolvedKind} isn't supported to foreclose, use mnemonic or private_key`, - ); - } -}; - /** * Work out how to sign as the guardian, the only account allowed to * foreclose. A signer given for the command takes precedence over the node's. @@ -108,6 +46,8 @@ export const resolveForecloseSigner = (options: { ); } + // the signer given for the command is passed down to cartesi-rollups-cli + const env = host ? hostSignerEnv(host) : undefined; const ignoredIndex = accountIndex === undefined ? {} @@ -123,13 +63,7 @@ export const resolveForecloseSigner = (options: { `The private key's address ${address} isn't the guardian ${guardian}`, ); } - return { - ...ignoredIndex, - env: { - CARTESI_AUTH_KIND: "private_key", - CARTESI_AUTH_PRIVATE_KEY: host.privateKey, - }, - }; + return { ...ignoredIndex, env }; } // the node's private key is never read, so it can't be checked here @@ -141,12 +75,6 @@ export const resolveForecloseSigner = (options: { return ignoredIndex; } - const env = host - ? { - CARTESI_AUTH_KIND: "mnemonic", - CARTESI_AUTH_MNEMONIC: host.mnemonic, - } - : undefined; if (accountIndex !== undefined) { return { accountIndex, env }; } @@ -192,7 +120,7 @@ Set CARTESI_AUTH_KIND to mnemonic or private_key when both are set.`, application, }); // the node signer is only needed without a signer for the command - const host = getHostSigner(); + const host = getHostSigner(getCartesiEnvironmentVariables()); const { accountIndex, env, warning } = resolveForecloseSigner({ accountIndex: options.accountIndex, guardian: withdrawalConfig.guardian, diff --git a/apps/cli/src/exec/node-container.ts b/apps/cli/src/exec/node-container.ts index ce6a3209..15a07e8e 100644 --- a/apps/cli/src/exec/node-container.ts +++ b/apps/cli/src/exec/node-container.ts @@ -1,4 +1,6 @@ import { ExecaError, execa } from "execa"; +import { type Hex, isHex } from "viem"; +import type { CartesiEnvironmentVariables } from "../base.js"; /** * Transaction options shared by the cartesi-rollups-cli commands that send a @@ -245,3 +247,72 @@ export const getNodeSigner = async (options: { }); return parseNodeSigner(stdout); }; + +/** + * Signer given for a single command, in the environment of the shell that + * runs it, so it can sign as an account the node doesn't sign with + */ +export type HostSigner = + | { kind: "mnemonic"; mnemonic: string } + | { kind: "private_key"; privateKey: Hex }; + +/** + * Read the per-command signer from the CARTESI_AUTH_* environment variables + * of the host, following cartesi-rollups-cli: CARTESI_AUTH_KIND picks the + * kind, otherwise the variable that is set does + * @returns the signer, or undefined when none is set + */ +export const getHostSigner = ( + env: CartesiEnvironmentVariables, +): HostSigner | undefined => { + const { + CARTESI_AUTH_KIND: kind, + CARTESI_AUTH_MNEMONIC: mnemonic, + CARTESI_AUTH_PRIVATE_KEY: privateKey, + } = env; + + if (!kind && !mnemonic && !privateKey) { + return undefined; + } + if (!kind && mnemonic && privateKey) { + throw new Error( + "Both CARTESI_AUTH_MNEMONIC and CARTESI_AUTH_PRIVATE_KEY are set, set CARTESI_AUTH_KIND to mnemonic or private_key to choose", + ); + } + + const resolvedKind = kind ?? (mnemonic ? "mnemonic" : "private_key"); + switch (resolvedKind) { + case "mnemonic": + if (!mnemonic) { + throw new Error( + "CARTESI_AUTH_KIND is mnemonic, but CARTESI_AUTH_MNEMONIC is not set", + ); + } + return { kind: "mnemonic", mnemonic }; + case "private_key": + if (!privateKey || !isHex(privateKey)) { + throw new Error( + "CARTESI_AUTH_KIND is private_key, but CARTESI_AUTH_PRIVATE_KEY is not a 0x-prefixed private key", + ); + } + return { kind: "private_key", privateKey }; + default: + throw new Error( + `CARTESI_AUTH_KIND ${resolvedKind} isn't supported for a single command, use mnemonic or private_key`, + ); + } +}; + +/** + * Environment that makes cartesi-rollups-cli sign with the given signer + */ +export const hostSignerEnv = (signer: HostSigner): Record => + signer.kind === "private_key" + ? { + CARTESI_AUTH_KIND: "private_key", + CARTESI_AUTH_PRIVATE_KEY: signer.privateKey, + } + : { + CARTESI_AUTH_KIND: "mnemonic", + CARTESI_AUTH_MNEMONIC: signer.mnemonic, + }; diff --git a/apps/cli/tests/unit/commands/foreclose.test.ts b/apps/cli/tests/unit/commands/foreclose.test.ts index b7b387fe..adb34669 100644 --- a/apps/cli/tests/unit/commands/foreclose.test.ts +++ b/apps/cli/tests/unit/commands/foreclose.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from "bun:test"; import { zeroAddress } from "viem"; import { mnemonicToAccount } from "viem/accounts"; -import { - getHostSigner, - resolveForecloseSigner, -} from "../../../src/commands/foreclose"; +import { resolveForecloseSigner } from "../../../src/commands/foreclose"; import { DEVNET_MNEMONIC } from "../../../src/compose/node"; // account 1 of the devnet mnemonic @@ -20,60 +17,6 @@ const otherGuardian = mnemonicToAccount(otherMnemonic, { addressIndex: 2, }).address; -describe("getHostSigner", () => { - it("should return undefined when nothing is set", () => { - expect(getHostSigner({})).toBeUndefined(); - }); - - it("should infer the kind from the variable that is set", () => { - expect(getHostSigner({ CARTESI_AUTH_MNEMONIC: "a b c" })).toEqual({ - kind: "mnemonic", - mnemonic: "a b c", - }); - expect( - getHostSigner({ CARTESI_AUTH_PRIVATE_KEY: guardianKey }), - ).toEqual({ kind: "private_key", privateKey: guardianKey }); - }); - - it("should let CARTESI_AUTH_KIND choose when both are set", () => { - const env = { - CARTESI_AUTH_MNEMONIC: "a b c", - CARTESI_AUTH_PRIVATE_KEY: guardianKey, - }; - expect(() => getHostSigner(env)).toThrow("CARTESI_AUTH_KIND"); - expect( - getHostSigner({ ...env, CARTESI_AUTH_KIND: "private_key" }), - ).toEqual({ kind: "private_key", privateKey: guardianKey }); - expect( - getHostSigner({ ...env, CARTESI_AUTH_KIND: "mnemonic" }), - ).toEqual({ kind: "mnemonic", mnemonic: "a b c" }); - }); - - it("should reject a kind without its variable", () => { - expect(() => getHostSigner({ CARTESI_AUTH_KIND: "mnemonic" })).toThrow( - "CARTESI_AUTH_MNEMONIC", - ); - expect(() => - getHostSigner({ CARTESI_AUTH_KIND: "private_key" }), - ).toThrow("CARTESI_AUTH_PRIVATE_KEY"); - }); - - it("should reject a private key that isn't hex", () => { - expect(() => - getHostSigner({ CARTESI_AUTH_PRIVATE_KEY: "not-a-key" }), - ).toThrow("0x-prefixed"); - }); - - it("should reject kinds not supported per command", () => { - expect(() => - getHostSigner({ CARTESI_AUTH_KIND: "mnemonic_file" }), - ).toThrow("mnemonic_file"); - expect(() => getHostSigner({ CARTESI_AUTH_KIND: "aws" })).toThrow( - "aws", - ); - }); -}); - describe("resolveForecloseSigner", () => { it("should reject an application without a withdrawal config", () => { expect(() => diff --git a/apps/cli/tests/unit/exec/node-container.test.ts b/apps/cli/tests/unit/exec/node-container.test.ts index 4dc4b0b7..84ec121c 100644 --- a/apps/cli/tests/unit/exec/node-container.test.ts +++ b/apps/cli/tests/unit/exec/node-container.test.ts @@ -4,6 +4,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { + getHostSigner, + hostSignerEnv, NODE_SIGNER_SCRIPT, nodeExecArgs, parseNodeSigner, @@ -237,3 +239,74 @@ describe("NODE_SIGNER_SCRIPT", () => { expect(parseNodeSigner(stdout)).toEqual({ kind: "private_key" }); }); }); + +// a well known anvil private key +const guardianKey = + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; + +describe("getHostSigner", () => { + it("should return undefined when nothing is set", () => { + expect(getHostSigner({})).toBeUndefined(); + }); + + it("should infer the kind from the variable that is set", () => { + expect(getHostSigner({ CARTESI_AUTH_MNEMONIC: "a b c" })).toEqual({ + kind: "mnemonic", + mnemonic: "a b c", + }); + expect( + getHostSigner({ CARTESI_AUTH_PRIVATE_KEY: guardianKey }), + ).toEqual({ kind: "private_key", privateKey: guardianKey }); + }); + + it("should let CARTESI_AUTH_KIND choose when both are set", () => { + const env = { + CARTESI_AUTH_MNEMONIC: "a b c", + CARTESI_AUTH_PRIVATE_KEY: guardianKey, + }; + expect(() => getHostSigner(env)).toThrow("CARTESI_AUTH_KIND"); + expect( + getHostSigner({ ...env, CARTESI_AUTH_KIND: "private_key" }), + ).toEqual({ kind: "private_key", privateKey: guardianKey }); + expect( + getHostSigner({ ...env, CARTESI_AUTH_KIND: "mnemonic" }), + ).toEqual({ kind: "mnemonic", mnemonic: "a b c" }); + }); + + it("should reject a kind without its variable", () => { + expect(() => getHostSigner({ CARTESI_AUTH_KIND: "mnemonic" })).toThrow( + "CARTESI_AUTH_MNEMONIC", + ); + expect(() => + getHostSigner({ CARTESI_AUTH_KIND: "private_key" }), + ).toThrow("CARTESI_AUTH_PRIVATE_KEY"); + }); + + it("should reject a private key that isn't hex", () => { + expect(() => + getHostSigner({ CARTESI_AUTH_PRIVATE_KEY: "not-a-key" }), + ).toThrow("0x-prefixed"); + }); + + it("should reject kinds not supported per command", () => { + expect(() => + getHostSigner({ CARTESI_AUTH_KIND: "mnemonic_file" }), + ).toThrow("mnemonic_file"); + expect(() => getHostSigner({ CARTESI_AUTH_KIND: "aws" })).toThrow( + "aws", + ); + }); + + it("should build the environment for each signer kind", () => { + expect(hostSignerEnv({ kind: "mnemonic", mnemonic: "a b c" })).toEqual({ + CARTESI_AUTH_KIND: "mnemonic", + CARTESI_AUTH_MNEMONIC: "a b c", + }); + expect( + hostSignerEnv({ kind: "private_key", privateKey: guardianKey }), + ).toEqual({ + CARTESI_AUTH_KIND: "private_key", + CARTESI_AUTH_PRIVATE_KEY: guardianKey, + }); + }); +}); From d0d09b736dd2fa584f3dddf4babc49610f73fd84 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 6 Oct 2026 18:43:23 +0100 Subject: [PATCH 20/25] refactor(cli): keep the command argument parsers together --- apps/cli/src/base.ts | 21 +++++++++++++++++++++ apps/cli/src/commands/refund.ts | 14 ++------------ apps/cli/src/node.ts | 14 +------------- apps/cli/tests/unit/commands/refund.test.ts | 3 ++- apps/cli/tests/unit/node.test.ts | 3 ++- 5 files changed, 28 insertions(+), 27 deletions(-) diff --git a/apps/cli/src/base.ts b/apps/cli/src/base.ts index d4e8e6c8..006914a9 100644 --- a/apps/cli/src/base.ts +++ b/apps/cli/src/base.ts @@ -241,3 +241,24 @@ export const parseHash = (value: string): Hash => { } return zeroHash; }; + +/** + * Commander parser of a mnemonic account index + */ +export const parseAccountIndex = (value: string): number => { + const index = Number(value); + if (!Number.isSafeInteger(index) || index < 0) { + throw new InvalidArgumentError("Not a valid account index."); + } + return index; +}; + +/** + * Commander parser of an input index, in decimal or 0x-prefixed hexadecimal + */ +export const parseInputIndex = (value: string): bigint => { + if (!/^(0x[0-9a-fA-F]+|[0-9]+)$/.test(value)) { + throw new InvalidArgumentError("Not a valid input index."); + } + return BigInt(value); +}; diff --git a/apps/cli/src/commands/refund.ts b/apps/cli/src/commands/refund.ts index f7ecdd14..3838bc18 100644 --- a/apps/cli/src/commands/refund.ts +++ b/apps/cli/src/commands/refund.ts @@ -1,9 +1,9 @@ import type { CartesiPublicClient } from "@cartesi/client"; -import { Command, InvalidArgumentError } from "@commander-js/extra-typings"; +import { Command } from "@commander-js/extra-typings"; import chalk from "chalk"; import fs from "fs-extra"; import { type Address, type Hex, isHex } from "viem"; -import { getProjectName } from "../base.js"; +import { getProjectName, parseInputIndex } from "../base.js"; import { handleNodeCommandError, removeNodePath, @@ -17,16 +17,6 @@ import { resolveNodeApplication, } from "../node.js"; -/** - * Commander parser of an input index, in decimal or 0x-prefixed hexadecimal - */ -export const parseInputIndex = (value: string): bigint => { - if (!/^(0x[0-9a-fA-F]+|[0-9]+)$/.test(value)) { - throw new InvalidArgumentError("Not a valid input index."); - } - return BigInt(value); -}; - /** * Get the complete InputAdded.input bytes of the deposit to refund, from a * file or from the node diff --git a/apps/cli/src/node.ts b/apps/cli/src/node.ts index 73f2deca..cf650f7d 100644 --- a/apps/cli/src/node.ts +++ b/apps/cli/src/node.ts @@ -4,7 +4,6 @@ import { } from "@cartesi/client"; import { type Command, - InvalidArgumentError, Option, type OptionValues, } from "@commander-js/extra-typings"; @@ -16,7 +15,7 @@ import { http, isAddress, } from "viem"; -import { getServiceState } from "./base.js"; +import { getServiceState, parseAccountIndex } from "./base.js"; import { getApplicationAddress, getProjectPort } from "./exec/rollups.js"; /** @@ -87,17 +86,6 @@ export const resolveNodeApplication = async (options: { return address; }; -/** - * Commander parser of a mnemonic account index - */ -export const parseAccountIndex = (value: string): number => { - const index = Number(value); - if (!Number.isSafeInteger(index) || index < 0) { - throw new InvalidArgumentError("Not a valid account index."); - } - return index; -}; - /** * Add the options shared by the fund recovery commands: the application and * project, the signer account index, and the transaction flags forwarded to diff --git a/apps/cli/tests/unit/commands/refund.test.ts b/apps/cli/tests/unit/commands/refund.test.ts index 0048f8dd..6a4dd771 100644 --- a/apps/cli/tests/unit/commands/refund.test.ts +++ b/apps/cli/tests/unit/commands/refund.test.ts @@ -2,7 +2,8 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { getInputData, parseInputIndex } from "../../../src/commands/refund"; +import { parseInputIndex } from "../../../src/base"; +import { getInputData } from "../../../src/commands/refund"; const application = "0x1234567890abcdef1234567890abcdef12345678"; diff --git a/apps/cli/tests/unit/node.test.ts b/apps/cli/tests/unit/node.test.ts index 24b69ccc..71a320b7 100644 --- a/apps/cli/tests/unit/node.test.ts +++ b/apps/cli/tests/unit/node.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { parseAccountIndex, resolveNodeApplication } from "../../src/node"; +import { parseAccountIndex } from "../../src/base"; +import { resolveNodeApplication } from "../../src/node"; describe("parseAccountIndex", () => { it("should parse a non-negative integer", () => { From 74795383f1967d18f561385d8e2b86c19e855c1e Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 6 Oct 2026 18:54:26 +0100 Subject: [PATCH 21/25] test(cli): share the unit test fixtures --- apps/cli/tests/unit/commands/refund.test.ts | 3 +-- apps/cli/tests/unit/commands/withdraw.test.ts | 3 +-- apps/cli/tests/unit/exec/cartesi-rollups-machine-tool.test.ts | 3 +-- apps/cli/tests/unit/fixtures.ts | 2 ++ 4 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 apps/cli/tests/unit/fixtures.ts diff --git a/apps/cli/tests/unit/commands/refund.test.ts b/apps/cli/tests/unit/commands/refund.test.ts index 6a4dd771..0cc03ac2 100644 --- a/apps/cli/tests/unit/commands/refund.test.ts +++ b/apps/cli/tests/unit/commands/refund.test.ts @@ -4,8 +4,7 @@ import os from "node:os"; import path from "node:path"; import { parseInputIndex } from "../../../src/base"; import { getInputData } from "../../../src/commands/refund"; - -const application = "0x1234567890abcdef1234567890abcdef12345678"; +import { application } from "../fixtures"; describe("parseInputIndex", () => { it("should parse decimal and 0x-prefixed hexadecimal indexes", () => { diff --git a/apps/cli/tests/unit/commands/withdraw.test.ts b/apps/cli/tests/unit/commands/withdraw.test.ts index 0505d61c..d937b270 100644 --- a/apps/cli/tests/unit/commands/withdraw.test.ts +++ b/apps/cli/tests/unit/commands/withdraw.test.ts @@ -7,9 +7,8 @@ import { replayStorePath, type WithdrawIo, } from "../../../src/commands/withdraw"; +import { account, application } from "../fixtures"; -const application = "0x1234567890abcdef1234567890abcdef12345678"; -const account = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; const root = toHex(1, { size: 32 }); const otherRoot = toHex(2, { size: 32 }); const fastRetry = { retries: 2, minTimeout: 1, maxTimeout: 1 }; diff --git a/apps/cli/tests/unit/exec/cartesi-rollups-machine-tool.test.ts b/apps/cli/tests/unit/exec/cartesi-rollups-machine-tool.test.ts index fa490405..4fcada00 100644 --- a/apps/cli/tests/unit/exec/cartesi-rollups-machine-tool.test.ts +++ b/apps/cli/tests/unit/exec/cartesi-rollups-machine-tool.test.ts @@ -7,9 +7,8 @@ import { proveAccountsDriveArgs, replayArgs, } from "../../../src/exec/cartesi-rollups-machine-tool"; +import { account, application } from "../fixtures"; -const application = "0x1234567890abcdef1234567890abcdef12345678"; -const account = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; const hash = (n: number) => toHex(n, { size: 32 }); describe("replayArgs", () => { diff --git a/apps/cli/tests/unit/fixtures.ts b/apps/cli/tests/unit/fixtures.ts new file mode 100644 index 00000000..0060d765 --- /dev/null +++ b/apps/cli/tests/unit/fixtures.ts @@ -0,0 +1,2 @@ +export const application = "0x1234567890abcdef1234567890abcdef12345678"; +export const account = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; From 4c7db51b8cd4ff7faf4f5ace5942ce432be8c6ce Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 6 Oct 2026 19:19:14 +0100 Subject: [PATCH 22/25] chore: Stop turbo from generating AGENTS.md files. --- turbo.json | 1 + 1 file changed, 1 insertion(+) diff --git a/turbo.json b/turbo.json index 85d36de1..8efc2d20 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,6 @@ { "$schema": "https://turbo.build/schema.json", + "agentGuidance": false, "globalEnv": [ "ALCHEMY_ID", "INFURA_ID", From 1b262821d3383216e2b7ea0aacacc437f8f34ba2 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 6 Oct 2026 19:19:42 +0100 Subject: [PATCH 23/25] chore(cli): Bump rollups-explorer to version 2.0.0-alpha.4 --- .changeset/brisk-owls-glow.md | 5 +++++ apps/cli/src/exec/rollups.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/brisk-owls-glow.md diff --git a/.changeset/brisk-owls-glow.md b/.changeset/brisk-owls-glow.md new file mode 100644 index 00000000..95a66452 --- /dev/null +++ b/.changeset/brisk-owls-glow.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Bump rollups-explorer to version 2.0.0-alpha.4 diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index 605d2688..50173148 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -376,7 +376,7 @@ export const startEnvironment = async (options: { if (services.includes("explorer")) { files.push( explorer({ - imageTag: "2.0.0-alpha.3", + imageTag: "2.0.0-alpha.4", port, }), ); From ac2ee403f86a43af2fb835ab09b39bdde297d981 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 6 Oct 2026 20:19:24 +0100 Subject: [PATCH 24/25] ci(cli): run the cli workflow on node 24. --- .github/workflows/cli.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/cli.yaml b/.github/workflows/cli.yaml index 691680b9..f8438122 100644 --- a/.github/workflows/cli.yaml +++ b/.github/workflows/cli.yaml @@ -30,6 +30,10 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - name: Install dependencies run: bun install --frozen-lockfile From 3cc85bd032cf9cd9ca3845ea04368cf68677ac26 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Wed, 7 Oct 2026 10:41:32 +0100 Subject: [PATCH 25/25] refactor(cli): read the node state through cartesi-rollups-cli in the container --- apps/cli/package.json | 1 - apps/cli/src/commands/foreclose.ts | 13 +- apps/cli/src/commands/refund.ts | 24 +-- apps/cli/src/commands/withdraw.ts | 49 +++--- apps/cli/src/exec/rollups.ts | 144 ++++++++++++++++++ apps/cli/src/node.ts | 18 --- .../integration/commands/recovery.test.ts | 36 +++-- apps/cli/tests/unit/commands/refund.test.ts | 23 +-- apps/cli/tests/unit/commands/withdraw.test.ts | 51 ++++--- apps/cli/tests/unit/exec/rollups.test.ts | 80 ++++++++++ bun.lock | 7 - 11 files changed, 326 insertions(+), 120 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index ad811aa7..8ef34934 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -15,7 +15,6 @@ "/dist" ], "dependencies": { - "@cartesi/client": "2.0.0-alpha.38", "@commander-js/extra-typings": "^15.0.0", "@inquirer/confirm": "^6.3.3", "@inquirer/core": "^12.0.4", diff --git a/apps/cli/src/commands/foreclose.ts b/apps/cli/src/commands/foreclose.ts index 1a7eb043..8725fabc 100644 --- a/apps/cli/src/commands/foreclose.ts +++ b/apps/cli/src/commands/foreclose.ts @@ -13,11 +13,8 @@ import { runNodeCommand, transactionArgs, } from "../exec/node-container.js"; -import { - getNodeClient, - addRecoveryOptions, - resolveNodeApplication, -} from "../node.js"; +import { getNodeWithdrawalConfig } from "../exec/rollups.js"; +import { addRecoveryOptions, resolveNodeApplication } from "../node.js"; import { findMnemonicAccountIndex } from "../wallet.js"; /** @@ -115,15 +112,15 @@ Set CARTESI_AUTH_KIND to mnemonic or private_key when both are set.`, projectName, }); - const client = await getNodeClient({ projectName }); - const { withdrawalConfig } = await client.getApplication({ + const { guardian } = await getNodeWithdrawalConfig({ application, + projectName, }); // the node signer is only needed without a signer for the command const host = getHostSigner(getCartesiEnvironmentVariables()); const { accountIndex, env, warning } = resolveForecloseSigner({ accountIndex: options.accountIndex, - guardian: withdrawalConfig.guardian, + guardian, host, node: host ? undefined : await getNodeSigner({ projectName }), }); diff --git a/apps/cli/src/commands/refund.ts b/apps/cli/src/commands/refund.ts index 3838bc18..8af854e0 100644 --- a/apps/cli/src/commands/refund.ts +++ b/apps/cli/src/commands/refund.ts @@ -1,4 +1,3 @@ -import type { CartesiPublicClient } from "@cartesi/client"; import { Command } from "@commander-js/extra-typings"; import chalk from "chalk"; import fs from "fs-extra"; @@ -11,11 +10,8 @@ import { transactionArgs, writeNodeTempFile, } from "../exec/node-container.js"; -import { - getNodeClient, - addRecoveryOptions, - resolveNodeApplication, -} from "../node.js"; +import { getNodeInput } from "../exec/rollups.js"; +import { addRecoveryOptions, resolveNodeApplication } from "../node.js"; /** * Get the complete InputAdded.input bytes of the deposit to refund, from a @@ -23,11 +19,18 @@ import { */ export const getInputData = async (options: { application: Address; - client: Pick; inputFile?: string; inputIndex: bigint; + io?: { getNodeInput: typeof getNodeInput }; + projectName: string; }): Promise => { - const { application, client, inputFile, inputIndex } = options; + const { + application, + inputFile, + inputIndex, + io = { getNodeInput }, + projectName, + } = options; if (inputFile) { const data = (await fs.readFile(inputFile, "utf-8")).trim(); @@ -40,8 +43,7 @@ export const getInputData = async (options: { } try { - const input = await client.getInput({ application, inputIndex }); - return input.rawData; + return await io.getNodeInput({ application, inputIndex, projectName }); } catch (error: unknown) { throw new Error( `Input ${inputIndex} not found in the node, use ${chalk.cyan("--input-file")} with the InputAdded.input bytes`, @@ -76,9 +78,9 @@ export const createRefundCommand = () => { const data = await getInputData({ application, - client: await getNodeClient({ projectName }), inputFile: options.inputFile, inputIndex, + projectName, }); // the node tool reads the input bytes from a file in its container diff --git a/apps/cli/src/commands/withdraw.ts b/apps/cli/src/commands/withdraw.ts index 56a3fc28..4055afe1 100644 --- a/apps/cli/src/commands/withdraw.ts +++ b/apps/cli/src/commands/withdraw.ts @@ -1,4 +1,3 @@ -import type { CartesiPublicClient } from "@cartesi/client"; import { Command, Option } from "@commander-js/extra-typings"; import chalk from "chalk"; import fs from "fs-extra"; @@ -25,10 +24,13 @@ import { proveAccountsDrive, replayMachine, } from "../exec/cartesi-rollups-machine-tool.js"; +import { + getLastAcceptedEpoch, + getNodeWithdrawalConfig, +} from "../exec/rollups.js"; import { addRecoveryOptions, getAnvilClient, - getNodeClient, resolveNodeApplication, } from "../node.js"; import { addressInput } from "../prompts.js"; @@ -58,6 +60,7 @@ export const proofStorePath = (application: Address, account: Address) => */ export type WithdrawIo = { execNodeCommand: typeof execNodeCommand; + getLastAcceptedEpoch: typeof getLastAcceptedEpoch; nodePathExists: typeof nodePathExists; proveAccountsDrive: typeof proveAccountsDrive; removeNodePath: typeof removeNodePath; @@ -66,6 +69,7 @@ export type WithdrawIo = { const defaultIo: WithdrawIo = { execNodeCommand, + getLastAcceptedEpoch, nodePathExists, proveAccountsDrive, removeNodePath, @@ -131,33 +135,32 @@ const isSameHash = (a: Hash, b: Hash) => a.toLowerCase() === b.toLowerCase(); */ const waitForFinalizedEpoch = async (options: { application: Address; - client: Pick; finalizedMachineRoot: Hash; + io: WithdrawIo; + projectName: string; retry: RetryOptions; }): Promise => { - const { application, client, finalizedMachineRoot, retry } = options; - let last: { epochIndex?: bigint; machineHash?: Hash | null } = {}; + const { application, finalizedMachineRoot, io, projectName, retry } = + options; + let last: { index?: bigint; machineHash?: Hash } = {}; try { return await pRetry(async () => { - const epochIndex = await client.getLastAcceptedEpochIndex({ + const epoch = await io.getLastAcceptedEpoch({ application, + projectName, }); - const { machineHash } = await client.getEpoch({ - application, - epochIndex, - }); - last = { epochIndex, machineHash }; + last = epoch ?? {}; if ( - !machineHash || - !isSameHash(machineHash, finalizedMachineRoot) + !epoch?.machineHash || + !isSameHash(epoch.machineHash, finalizedMachineRoot) ) { throw new Error("node is behind the chain"); } - return epochIndex; + return epoch.index; }, retry); } catch { throw new Error( - `The node hasn't processed the last finalized epoch yet (node epoch ${last.epochIndex ?? "none"} root ${last.machineHash ?? "none"}, chain root ${finalizedMachineRoot}); try again shortly`, + `The node hasn't processed the last finalized epoch yet (node epoch ${last.index ?? "none"} root ${last.machineHash ?? "none"}, chain root ${finalizedMachineRoot}); try again shortly`, ); } }; @@ -170,7 +173,6 @@ const waitForFinalizedEpoch = async (options: { */ export const replayLastFinalizedEpoch = async (options: { application: Address; - client: Pick; finalizedMachineRoot: Hash; io?: WithdrawIo; projectName: string; @@ -178,7 +180,6 @@ export const replayLastFinalizedEpoch = async (options: { }): Promise<{ store: string }> => { const { application, - client, finalizedMachineRoot, io = defaultIo, projectName, @@ -193,8 +194,9 @@ export const replayLastFinalizedEpoch = async (options: { try { epochIndex = await waitForFinalizedEpoch({ application, - client, finalizedMachineRoot, + io, + projectName, retry, }); } catch (error: unknown) { @@ -300,7 +302,6 @@ const proveAccount = async (options: { account: Address; accountIndex?: number; application: Address; - client: CartesiPublicClient; driveRootProven: boolean; finalizedMachineRoot: Hash; projectName: string; @@ -310,19 +311,18 @@ const proveAccount = async (options: { account, accountIndex, application, - client, driveRootProven, finalizedMachineRoot, projectName, tx, } = options; - const { withdrawalConfig } = await client.getApplication({ + const driveConfig = await getNodeWithdrawalConfig({ application, + projectName, }); const { store } = await replayLastFinalizedEpoch({ application, - client, finalizedMachineRoot, projectName, }); @@ -330,7 +330,7 @@ const proveAccount = async (options: { const proof = await generateAccountProofs({ account, application, - driveConfig: withdrawalConfig, + driveConfig, finalizedMachineRoot, projectName, store, @@ -384,8 +384,6 @@ export const createWithdrawCommand = () => { application: options.application, projectName, }); - const client = await getNodeClient({ projectName }); - const { driveRootProven, finalizedMachineRoot, foreclosed } = await readForeclosureState({ application, projectName }); if (!foreclosed) { @@ -418,7 +416,6 @@ export const createWithdrawCommand = () => { account, accountIndex, application, - client, driveRootProven, finalizedMachineRoot, projectName, diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index 50173148..d1935068 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -8,8 +8,10 @@ import { type Hex, createPublicClient, getAddress, + hexToBigInt, hexToNumber, http, + isAddressEqual, } from "viem"; import { stringify } from "yaml"; import { @@ -30,6 +32,7 @@ import paymaster from "../compose/paymaster.js"; import proxy from "../compose/proxy.js"; import type { WithdrawalConfig } from "../config.js"; import type { ForkConfig } from "../types/chain.js"; +import type { AccountsDriveConfig } from "./cartesi-rollups-machine-tool.js"; import { execNodeCommand } from "./node-container.js"; /** @@ -61,6 +64,14 @@ export const TERMINAL_APPLICATION_STATUSES: readonly ApplicationStatus[] = [ "INVALID_OUTPUTS_ROOT", ]; +/** + * Withdrawal config of an application, as registered in the node + */ +export type NodeWithdrawalConfig = AccountsDriveConfig & { + guardian: Address; + withdrawalOutputBuilder: Address; +}; + export type RollupsDeployment = { name: string; address: Address; @@ -71,6 +82,15 @@ export type RollupsDeployment = { /** Node-provided diagnostic, present when the status is not `OK`. */ reason?: string; enabled: boolean; + withdrawalConfig?: NodeWithdrawalConfig; +}; + +type CliWithdrawalConfig = { + guardian: string; + log2_leaves_per_account: string; + log2_max_num_of_accounts: string; + accounts_drive_start_index: string; + withdrawal_output_builder: string; }; type CliRollupsDeployment = { @@ -82,12 +102,25 @@ type CliRollupsDeployment = { status: string; reason?: string | null; enabled: boolean; + withdrawal_config?: CliWithdrawalConfig | null; }; type ComposeParams = { projectName: string; }; +const parseWithdrawalConfig = ( + config: CliWithdrawalConfig, +): NodeWithdrawalConfig => ({ + accountsDriveStartIndex: hexToBigInt( + config.accounts_drive_start_index as Hex, + ), + guardian: getAddress(config.guardian), + log2LeavesPerAccount: hexToBigInt(config.log2_leaves_per_account as Hex), + log2MaxNumOfAccounts: hexToBigInt(config.log2_max_num_of_accounts as Hex), + withdrawalOutputBuilder: getAddress(config.withdrawal_output_builder), +}); + const parseDeployment = ( deployment: CliRollupsDeployment, ): RollupsDeployment => ({ @@ -99,6 +132,9 @@ const parseDeployment = ( reason: deployment.reason ?? undefined, enabled: deployment.enabled, templateHash: deployment.template_hash as Hex, + withdrawalConfig: deployment.withdrawal_config + ? parseWithdrawalConfig(deployment.withdrawal_config) + : undefined, }); /** @@ -122,6 +158,114 @@ export const getDeployments = async ( } }; +/** + * Read the withdrawal config of an application registered in the node + */ +export const getNodeWithdrawalConfig = async (options: { + application: Address; + projectName: string; +}): Promise => { + const { application, projectName } = options; + const stdout = await execNodeCommand({ + command: ["cartesi-rollups-cli", "app", "list"], + projectName, + }); + const deployment = parseApplications(stdout).find((deployment) => + isAddressEqual(deployment.address, application), + ); + if (!deployment) { + throw new Error( + `Application ${application} isn't registered in the node`, + ); + } + if (!deployment.withdrawalConfig) { + throw new Error( + `The node has no withdrawal config for application ${application}`, + ); + } + return deployment.withdrawalConfig; +}; + +/** + * Parse the JSON `cartesi-rollups-cli read inputs ` + * writes to stdout + * @returns the input bytes, as emitted in InputAdded.input + */ +export const parseNodeInput = (stdout: string): Hex => + (JSON.parse(stdout) as { data: { raw_data: Hex } }).data.raw_data; + +/** + * Read the bytes of an input from the node database + */ +export const getNodeInput = async (options: { + application: Address; + inputIndex: bigint; + projectName: string; +}): Promise => { + const { application, inputIndex, projectName } = options; + const stdout = await execNodeCommand({ + command: [ + "cartesi-rollups-cli", + "read", + "inputs", + application, + inputIndex.toString(), + ], + projectName, + }); + return parseNodeInput(stdout); +}; + +export type NodeEpoch = { + index: bigint; + machineHash?: Hash; +}; + +/** + * Parse the JSON `cartesi-rollups-cli read epochs` writes to stdout for a + * single epoch listed + * @returns the epoch, or undefined if none is listed + */ +export const parseLastAcceptedEpoch = ( + stdout: string, +): NodeEpoch | undefined => { + const { data } = JSON.parse(stdout) as { + data: { index: Hex; machine_hash: Hash | null }[]; + }; + const [epoch] = data; + return epoch + ? { + index: hexToBigInt(epoch.index), + machineHash: epoch.machine_hash ?? undefined, + } + : undefined; +}; + +/** + * Read the last epoch of an application whose claim the node saw accepted + */ +export const getLastAcceptedEpoch = async (options: { + application: Address; + projectName: string; +}): Promise => { + const { application, projectName } = options; + const stdout = await execNodeCommand({ + command: [ + "cartesi-rollups-cli", + "read", + "epochs", + application, + "--status", + "CLAIM_ACCEPTED", + "--descending", + "--limit", + "1", + ], + projectName, + }); + return parseLastAcceptedEpoch(stdout); +}; + export const getApplicationDeployment = async ( options: ComposeParams, ): Promise => { diff --git a/apps/cli/src/node.ts b/apps/cli/src/node.ts index cf650f7d..9d45d203 100644 --- a/apps/cli/src/node.ts +++ b/apps/cli/src/node.ts @@ -1,7 +1,3 @@ -import { - type CartesiPublicClient, - createCartesiPublicClient, -} from "@cartesi/client"; import { type Command, Option, @@ -18,20 +14,6 @@ import { import { getServiceState, parseAccountIndex } from "./base.js"; import { getApplicationAddress, getProjectPort } from "./exec/rollups.js"; -/** - * Create a client for the JSON-RPC API of the local rollups node - * @param options projectName - * @returns client exposing the cartesi_* methods - */ -export const getNodeClient = async (options: { - projectName: string; -}): Promise => { - const host = await getProjectPort(options); - return createCartesiPublicClient({ - transport: http(`http://${host}/rpc`), - }); -}; - /** * Create a read-only client for the anvil of the local environment * @param options projectName diff --git a/apps/cli/tests/integration/commands/recovery.test.ts b/apps/cli/tests/integration/commands/recovery.test.ts index bad1ce81..97914353 100644 --- a/apps/cli/tests/integration/commands/recovery.test.ts +++ b/apps/cli/tests/integration/commands/recovery.test.ts @@ -1,4 +1,3 @@ -import { createCartesiPublicClient } from "@cartesi/client"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { type ResultPromise, execa } from "execa"; import fs from "fs-extra"; @@ -25,6 +24,9 @@ import { import { replayStorePath } from "../../../src/commands/withdraw"; import { getDeployments, + getLastAcceptedEpoch, + getNodeInput, + getNodeWithdrawalConfig, getProjectPort, stopEnvironment, } from "../../../src/exec/rollups"; @@ -94,7 +96,6 @@ describe("fund recovery", () => { let run: ResultPromise | undefined; let application: Address; let anvil: Awaited>; - let node: ReturnType; const cli = (args: string[], env?: Record) => execa("node", [cliPath, ...args, "--project-name", projectName], { @@ -253,10 +254,6 @@ withdrawal_output_builder = "${testUsdWithdrawalOutputBuilderAddress}" console.log(`✓ Application deployed at ${application}`); anvil = await createAnvilClient(); - const host = await getProjectPort({ projectName }); - node = createCartesiPublicClient({ - transport: http(`http://${host}/rpc`), - }); }, TIMEOUT); afterAll(async () => { @@ -275,17 +272,26 @@ withdrawal_output_builder = "${testUsdWithdrawalOutputBuilderAddress}" // close the first epoch and wait for its claim to be accepted await anvil.mine({ blocks: EPOCH_LENGTH }); console.log("! Waiting for epoch 0 to be accepted..."); - const epochIndex = await waitFor(() => - node.getLastAcceptedEpochIndex({ application }), - ); - expect(epochIndex).toBe(0n); + const epoch = await waitFor(async () => { + const accepted = await getLastAcceptedEpoch({ + application, + projectName, + }); + if (!accepted) { + throw new Error("no accepted epoch yet"); + } + return accepted; + }); + expect(epoch.index).toBe(0n); console.log("✓ Epoch 0 accepted"); expect(await usdcBalance(alice)).toBe(0n); // these stay in the open epoch, never finalized await deposit(carol, amounts.carol); await deposit(dave, amounts.dave); - await waitFor(() => node.getInput({ application, inputIndex: 4n })); + await waitFor(() => + getNodeInput({ application, inputIndex: 4n, projectName }), + ); }, TIMEOUT, ); @@ -377,9 +383,10 @@ withdrawal_output_builder = "${testUsdWithdrawalOutputBuilderAddress}" it( "should refund with the input bytes from a file", async () => { - const { rawData } = await node.getInput({ + const rawData = await getNodeInput({ application, inputIndex: 4n, + projectName, }); const inputFile = path.join(appDir, "dave-input.hex"); await fs.writeFile(inputFile, rawData); @@ -464,12 +471,13 @@ withdrawal_output_builder = "${testUsdWithdrawalOutputBuilderAddress}" async () => { // generate erin's proof from the cached replay, as an outside // tool would, and withdraw through the file path - const { withdrawalConfig } = await node.getApplication({ + const driveConfig = await getNodeWithdrawalConfig({ application, + projectName, }); const proof = await proveAccountsDrive({ account: erin, - driveConfig: withdrawalConfig, + driveConfig, outDir: `/tmp/recovery-test/${erin}`, projectName, snapshot: replayStorePath(application, 0n), diff --git a/apps/cli/tests/unit/commands/refund.test.ts b/apps/cli/tests/unit/commands/refund.test.ts index 0cc03ac2..e10b3a00 100644 --- a/apps/cli/tests/unit/commands/refund.test.ts +++ b/apps/cli/tests/unit/commands/refund.test.ts @@ -28,8 +28,8 @@ describe("getInputData", () => { fs.writeFileSync(filePath, content); return filePath; }; - const unusedClient = { - getInput: () => { + const unusedNode = { + getNodeInput: () => { throw new Error("the node must not be queried"); }, }; @@ -46,9 +46,10 @@ describe("getInputData", () => { expect( await getInputData({ application, - client: unusedClient, inputFile: file("valid.hex", " 0xdeadbeef\n"), inputIndex: 2n, + io: unusedNode, + projectName: "dapp", }), ).toBe("0xdeadbeef"); }); @@ -58,38 +59,38 @@ describe("getInputData", () => { await expect( getInputData({ application, - client: unusedClient, inputFile: file("invalid.hex", content), inputIndex: 2n, + io: unusedNode, + projectName: "dapp", }), ).rejects.toThrow("must contain the 0x-prefixed hexadecimal"); } }); it("should read the input from the node", async () => { - const client = { - getInput: async () => ({ rawData: "0xcafe" as const }), - }; expect( await getInputData({ application, - client: client as never, inputIndex: 2n, + io: { getNodeInput: async () => "0xcafe" }, + projectName: "dapp", }), ).toBe("0xcafe"); }); it("should point to --input-file when the node doesn't have the input", async () => { - const client = { - getInput: async () => { + const io = { + getNodeInput: async () => { throw new Error("not found"); }, }; await expect( getInputData({ application, - client: client as never, inputIndex: 99n, + io, + projectName: "dapp", }), ).rejects.toThrow(/Input 99 not found in the node, use .*--input-file/); }); diff --git a/apps/cli/tests/unit/commands/withdraw.test.ts b/apps/cli/tests/unit/commands/withdraw.test.ts index d937b270..fc200f6c 100644 --- a/apps/cli/tests/unit/commands/withdraw.test.ts +++ b/apps/cli/tests/unit/commands/withdraw.test.ts @@ -13,30 +13,28 @@ const root = toHex(1, { size: 32 }); const otherRoot = toHex(2, { size: 32 }); const fastRetry = { retries: 2, minTimeout: 1, maxTimeout: 1 }; -/** Node client answering with the given (epoch index, machine hash) in turn */ -const nodeClient = (...epochs: [bigint, Hash | null][]) => { - let call = 0; - return { - getLastAcceptedEpochIndex: async () => - epochs[Math.min(call++, epochs.length - 1)][0], - getEpoch: async ({ epochIndex }: { epochIndex: bigint }) => ({ - machineHash: epochs.find(([index]) => index === epochIndex)?.[1], - }), - } as never; -}; - -/** I/O that records its calls, with the given cache state and results */ +/** + * I/O that records its calls, with the given cache state and results. The + * node reports the given (epoch index, machine hash) as last accepted in turn. + */ const recordingIo = (options: { cached?: boolean; + epochs?: [bigint, Hash][]; proofRoot?: Hash; replayRoot?: Hash; }) => { const calls: string[] = []; + const epochs = options.epochs ?? []; + let epochCall = 0; const io: WithdrawIo = { execNodeCommand: async ({ command }) => { calls.push(command.join(" ")); return ""; }, + getLastAcceptedEpoch: async () => { + const epoch = epochs[Math.min(epochCall++, epochs.length - 1)]; + return epoch && { index: epoch[0], machineHash: epoch[1] }; + }, nodePathExists: async ({ path }) => { calls.push(`exists ${path}`); return options.cached ?? false; @@ -91,7 +89,6 @@ describe("replayLastFinalizedEpoch", () => { await expect( replayLastFinalizedEpoch({ application, - client: nodeClient(), finalizedMachineRoot: zeroHash, io, projectName: "dapp", @@ -101,11 +98,13 @@ describe("replayLastFinalizedEpoch", () => { }); it("should reuse a cached snapshot", async () => { - const { calls, io } = recordingIo({ cached: true }); + const { calls, io } = recordingIo({ + cached: true, + epochs: [[1n, root]], + }); expect( await replayLastFinalizedEpoch({ application, - client: nodeClient([1n, root]), finalizedMachineRoot: root, io, projectName: "dapp", @@ -115,11 +114,10 @@ describe("replayLastFinalizedEpoch", () => { }); it("should replay into a temporary path and move it into the cache", async () => { - const { calls, io } = recordingIo({}); + const { calls, io } = recordingIo({ epochs: [[1n, root]] }); expect( await replayLastFinalizedEpoch({ application, - client: nodeClient([1n, root]), finalizedMachineRoot: root, io, projectName: "dapp", @@ -134,11 +132,13 @@ describe("replayLastFinalizedEpoch", () => { }); it("should not cache a replay whose root isn't the finalized one", async () => { - const { calls, io } = recordingIo({ replayRoot: otherRoot }); + const { calls, io } = recordingIo({ + epochs: [[1n, root]], + replayRoot: otherRoot, + }); await expect( replayLastFinalizedEpoch({ application, - client: nodeClient([1n, root]), finalizedMachineRoot: root, io, projectName: "dapp", @@ -153,11 +153,15 @@ describe("replayLastFinalizedEpoch", () => { }); it("should wait for the node to reach the finalized epoch", async () => { - const { calls, io } = recordingIo({}); + const { calls, io } = recordingIo({ + epochs: [ + [0n, otherRoot], + [1n, root], + ], + }); expect( await replayLastFinalizedEpoch({ application, - client: nodeClient([0n, otherRoot], [1n, root]), finalizedMachineRoot: root, io, projectName: "dapp", @@ -168,11 +172,10 @@ describe("replayLastFinalizedEpoch", () => { }); it("should stop when the node never reaches the finalized epoch", async () => { - const { calls, io } = recordingIo({}); + const { calls, io } = recordingIo({ epochs: [[0n, otherRoot]] }); await expect( replayLastFinalizedEpoch({ application, - client: nodeClient([0n, otherRoot]), finalizedMachineRoot: root, io, projectName: "dapp", diff --git a/apps/cli/tests/unit/exec/rollups.test.ts b/apps/cli/tests/unit/exec/rollups.test.ts index e9a153af..57a53d7b 100644 --- a/apps/cli/tests/unit/exec/rollups.test.ts +++ b/apps/cli/tests/unit/exec/rollups.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "bun:test"; import { buildDeployApplicationArgs, parseApplications, + parseLastAcceptedEpoch, + parseNodeInput, } from "../../../src/exec/rollups"; /** A single entry as `cartesi-rollups-cli app list` prints it. */ @@ -108,6 +110,38 @@ describe("parseApplications", () => { expect(deployments[1].status).toBe("MACHINE_HALTED"); }); + it("should decode the withdrawal config the node registered", () => { + const [deployment] = parseApplications( + stdout([ + appListEntry({ + withdrawal_config: { + guardian: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", + log2_leaves_per_account: "0x0", + log2_max_num_of_accounts: "0x11", + accounts_drive_start_index: "0x240", + withdrawal_output_builder: + "0xb4d253c7a110241561b3ed6d632846df7d4e9af7", + }, + }), + ]), + ); + + expect(deployment.withdrawalConfig).toEqual({ + accountsDriveStartIndex: 0x240n, + guardian: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + log2LeavesPerAccount: 0n, + log2MaxNumOfAccounts: 17n, + withdrawalOutputBuilder: + "0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7", + }); + }); + + it("should leave the withdrawal config undefined when the node omits it", () => { + const [deployment] = parseApplications(stdout([appListEntry()])); + + expect(deployment.withdrawalConfig).toBeUndefined(); + }); + it("should return no deployments when none are registered", () => { expect(parseApplications(stdout([]))).toEqual([]); }); @@ -119,6 +153,52 @@ describe("parseApplications", () => { }); }); +describe("parseNodeInput", () => { + it("should return the input bytes of `read inputs`", () => { + const stdout = JSON.stringify({ + data: { index: "0x4", raw_data: "0x415bf363cafe", status: "NONE" }, + }); + + expect(parseNodeInput(stdout)).toBe("0x415bf363cafe"); + }); +}); + +describe("parseLastAcceptedEpoch", () => { + const machineHash = + "0xc3595fc3b48b90fcf15cf935bcb92c24ae0d984e35221643776e079319eda1ba"; + const stdout = (data: unknown[]) => + JSON.stringify({ + data, + pagination: { limit: 1, offset: 0, total_count: data.length }, + }); + + it("should return the index and machine hash of the listed epoch", () => { + expect( + parseLastAcceptedEpoch( + stdout([ + { + index: "0x1", + machine_hash: machineHash, + status: "CLAIM_ACCEPTED", + }, + ]), + ), + ).toEqual({ index: 1n, machineHash }); + }); + + it("should return undefined when no epoch was accepted", () => { + expect(parseLastAcceptedEpoch(stdout([]))).toBeUndefined(); + }); + + it("should leave the machine hash undefined when the node has none", () => { + expect( + parseLastAcceptedEpoch( + stdout([{ index: "0x0", machine_hash: null }]), + ), + ).toEqual({ index: 0n, machineHash: undefined }); + }); +}); + describe("buildDeployApplicationArgs", () => { const base = { claimStagingPeriod: 720, diff --git a/bun.lock b/bun.lock index b0896ebe..789dca49 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,6 @@ "cartesi": "./dist/index.js", }, "dependencies": { - "@cartesi/client": "2.0.0-alpha.38", "@commander-js/extra-typings": "^15.0.0", "@inquirer/confirm": "^6.3.3", "@inquirer/core": "^12.0.4", @@ -138,14 +137,10 @@ "@cartesi/cli": ["@cartesi/cli@workspace:apps/cli"], - "@cartesi/client": ["@cartesi/client@2.0.0-alpha.38", "", { "dependencies": { "@cartesi/rpc": "2.0.0-alpha.27", "abitype": "^1.3.0", "p-retry": "^8.0.1" }, "peerDependencies": { "viem": "^2.0.0" } }, "sha512-XtYPLIKDvJClbl8Sb9/Mx2yK4Q7FJD8CPG3Gq2FCdNkgVZ0si7K8LhIFetnukiVIU1KF4aYJEtiIjgLizmnzRQ=="], - "@cartesi/devnet": ["@cartesi/devnet@workspace:packages/devnet"], "@cartesi/mock-verifying-paymaster": ["@cartesi/mock-verifying-paymaster@workspace:packages/mock-verifying-paymaster"], - "@cartesi/rpc": ["@cartesi/rpc@2.0.0-alpha.27", "", { "dependencies": { "json-rpc-2.0": "^1.8.0" } }, "sha512-mkZIELszlnulJYZglMuANuMel71uWcENnAl/LnNdpwaU6o8hq0o7f9y3JaBTwwjWOo73s2gOpyhc3cU1Kq/m+w=="], - "@cartesi/sdk": ["@cartesi/sdk@workspace:packages/sdk"], "@cartesi/wagmi-plugin": ["@cartesi/wagmi-plugin@1.0.0-alpha.7", "", { "dependencies": { "abitype": "^1.3.0", "modern-tar": "^0.8.4" }, "peerDependencies": { "@wagmi/cli": "^2.10.0", "viem": "^2.0.0" } }, "sha512-oVgBBMxuEd/xa5OSxToGjRf7nOA9OyjXLmCyH8V19DjZ+k9GNFFfZSDsk7bbDHtia1Vnq4nl6aNU6dU0K7po3w=="], @@ -802,8 +797,6 @@ "json-parse-better-errors": ["json-parse-better-errors@1.0.2", "", {}, "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="], - "json-rpc-2.0": ["json-rpc-2.0@1.8.1", "", {}, "sha512-BmN42JKn6FI36pcMMzHCXQhpKNEfbeqgSIcUYHKnifq0/4Ey6PDinovwUOgajT7NsgUtpWdDXaCWlpI4LHXN3g=="], - "json-schema-ref-resolver": ["json-schema-ref-resolver@1.0.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3" } }, "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],