Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8c77235
feat(cli): add cartesi client to query the local node
brunomenezes Oct 1, 2026
dc2eceb
feat(cli): add helpers to run rollups node tools in the node container
brunomenezes Oct 1, 2026
3c97d7c
feat(cli): add foreclose command signing as the application guardian
brunomenezes Oct 1, 2026
6f9efe0
feat(cli): add refund command
brunomenezes Oct 1, 2026
a0ae450
feat(cli): add withdraw command generating proofs from the accepted e…
brunomenezes Oct 1, 2026
076b439
test(cli): cover foreclose, refund and withdraw against the sdk
brunomenezes Oct 1, 2026
ffa1f30
chore(cli): add changeset for the fund recovery commands
brunomenezes Oct 1, 2026
2ace39b
docs: list foreclose, refund and withdraw commands in CLAUDE.md
brunomenezes Oct 1, 2026
a41ff0e
test(cli): spy on createPublicClient instead of mocking viem
brunomenezes Oct 2, 2026
e07ad9f
feat(cli): let foreclose sign as a guardian outside the node signer
brunomenezes Oct 6, 2026
8a55e8a
fix(cli): replay and cache only the epoch the chain finalized
brunomenezes Oct 6, 2026
fa7d0d4
test(cli): cover the refund, withdraw and application resolution logic
brunomenezes Oct 6, 2026
47d9d80
fix(cli): explain why a proof file needs a proven drive root
brunomenezes Oct 6, 2026
3006d18
test(cli): cover recovery error paths against the devnet
brunomenezes Oct 6, 2026
71cf9fc
refactor(cli): split the node container and machine tool helpers out …
brunomenezes Oct 6, 2026
8c0379d
refactor(cli): run the existing node commands through the container h…
brunomenezes Oct 6, 2026
06ca5a7
refactor(cli): share the recovery command options
brunomenezes Oct 6, 2026
fd2c835
refactor(cli): tidy the withdraw steps
brunomenezes Oct 6, 2026
be6c794
refactor(cli): keep the signer helpers together
brunomenezes Oct 6, 2026
d0d09b7
refactor(cli): keep the command argument parsers together
brunomenezes Oct 6, 2026
7479538
test(cli): share the unit test fixtures
brunomenezes Oct 6, 2026
4c7db51
chore: Stop turbo from generating AGENTS.md files.
brunomenezes Oct 6, 2026
1b26282
chore(cli): Bump rollups-explorer to version 2.0.0-alpha.4
brunomenezes Oct 6, 2026
ac2ee40
ci(cli): run the cli workflow on node 24.
brunomenezes Oct 6, 2026
3cc85bd
refactor(cli): read the node state through cartesi-rollups-cli in the…
brunomenezes Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brisk-owls-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cartesi/cli": patch
---

Bump rollups-explorer to version 2.0.0-alpha.4
13 changes: 13 additions & 0 deletions .changeset/common-insects-punch.md
Original file line number Diff line number Diff line change
@@ -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 <input-index>` 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 <address>` 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.
4 changes: 4 additions & 0 deletions .github/workflows/cli.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
21 changes: 21 additions & 0 deletions apps/cli/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
147 changes: 147 additions & 0 deletions apps/cli/src/commands/foreclose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { Command } from "@commander-js/extra-typings";
import chalk from "chalk";
import { type Address, isAddressEqual, zeroAddress } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { getCartesiEnvironmentVariables, getProjectName } from "../base.js";
import {
getHostSigner,
getNodeSigner,
handleNodeCommandError,
type HostSigner,
hostSignerEnv,
type NodeSigner,
runNodeCommand,
transactionArgs,
} from "../exec/node-container.js";
import { getNodeWithdrawalConfig } from "../exec/rollups.js";
import { addRecoveryOptions, resolveNodeApplication } from "../node.js";
import { findMnemonicAccountIndex } from "../wallet.js";

/**
* 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<string, string>;
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",
);
}

// the signer given for the command is passed down to cartesi-rollups-cli
const env = host ? hostSignerEnv(host) : undefined;
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 };
}

// 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;
}

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 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="<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 }),
"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,
});

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,
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);
}
});
};
111 changes: 111 additions & 0 deletions apps/cli/src/commands/refund.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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, parseInputIndex } from "../base.js";
import {
handleNodeCommandError,
removeNodePath,
runNodeCommand,
transactionArgs,
writeNodeTempFile,
} from "../exec/node-container.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
* file or from the node
*/
export const getInputData = async (options: {
application: Address;
inputFile?: string;
inputIndex: bigint;
io?: { getNodeInput: typeof getNodeInput };
projectName: string;
}): Promise<Hex> => {
const {
application,
inputFile,
inputIndex,
io = { getNodeInput },
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;
}

try {
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`,
{ cause: error },
);
}
};

export const createRefundCommand = () => {
return addRecoveryOptions(
new Command("refund")
.description(
"Refunds a deposit that was not finalized before the application was foreclosed",
)
.configureHelp({ showGlobalOptions: true })
.argument(
"<input-index>",
"index of the deposit input",
parseInputIndex,
)
.option(
"--input-file <path>",
"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,
});

// 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 });
}
});
};
Loading
Loading