diff --git a/.changeset/busy-rats-leave.md b/.changeset/busy-rats-leave.md new file mode 100644 index 00000000..f6e3c79f --- /dev/null +++ b/.changeset/busy-rats-leave.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Add support to read and check a withdrawal config through a toml file. diff --git a/.changeset/chubby-tires-melt.md b/.changeset/chubby-tires-melt.md new file mode 100644 index 00000000..8c57aef7 --- /dev/null +++ b/.changeset/chubby-tires-melt.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Update docker flag by adding tty when using the shell command. It fixes "unable to open a TTY" error returned by cartesi-machine. diff --git a/.changeset/clever-bees-taste.md b/.changeset/clever-bees-taste.md new file mode 100644 index 00000000..29dace3a --- /dev/null +++ b/.changeset/clever-bees-taste.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Bump cartesi/sdk image to version 0.12.0-alpha.41. diff --git a/.changeset/funny-words-occur.md b/.changeset/funny-words-occur.md new file mode 100644 index 00000000..97ecfc38 --- /dev/null +++ b/.changeset/funny-words-occur.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Remove CARTESI_BLOCKCHAIN_WS_ENDPOINT environment variable from the node docker compose file construction. It is not supported by rollups-node alpha.12 diff --git a/.changeset/good-dodos-love.md b/.changeset/good-dodos-love.md new file mode 100644 index 00000000..42a35cd0 --- /dev/null +++ b/.changeset/good-dodos-love.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Bump @cartesi/devnet package to alpha.14 and remove @cartesi/rollups package. diff --git a/.changeset/huge-games-fail.md b/.changeset/huge-games-fail.md new file mode 100644 index 00000000..21ffe93b --- /dev/null +++ b/.changeset/huge-games-fail.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Refactor how to retrieve the machine-hash from the image built. the hash file is not generated in the new emulator 0.20.0, instead it generates a hash_tree.sht file where the hash is. diff --git a/.changeset/legal-dragons-agree.md b/.changeset/legal-dragons-agree.md new file mode 100644 index 00000000..cb65380b --- /dev/null +++ b/.changeset/legal-dragons-agree.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Add new TestUsdWithdrawalOutputBuilder to be listed in the address-book. Also, refactor deposits ERC-20 and ERC-721 to use new Fungible and non-fungible test token addresses. diff --git a/.changeset/light-singers-slide.md b/.changeset/light-singers-slide.md new file mode 100644 index 00000000..4aca74fb --- /dev/null +++ b/.changeset/light-singers-slide.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Add CORS declaration to the node proxy rules when building the docker compose file. diff --git a/.changeset/lucky-otters-write.md b/.changeset/lucky-otters-write.md new file mode 100644 index 00000000..b635381b --- /dev/null +++ b/.changeset/lucky-otters-write.md @@ -0,0 +1,6 @@ +--- +"@cartesi/cli": patch +--- + +Add support to modify rollups-node environment variables by setting your own CARTESI\_\* environment variables in the host machine e.g. CARTESI_AUTH_MNEMONIC and CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX. This facilitates configuration when testing foreclosure/emergency-withdraws. + diff --git a/.changeset/metal-otters-say.md b/.changeset/metal-otters-say.md new file mode 100644 index 00000000..f060a1f4 --- /dev/null +++ b/.changeset/metal-otters-say.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Add new --list-supported-variables to RUN command to return a JSON object by service listing all supported variables to that specific service e.g. rollups-node diff --git a/.changeset/soft-trees-think.md b/.changeset/soft-trees-think.md new file mode 100644 index 00000000..fd1c5899 --- /dev/null +++ b/.changeset/soft-trees-think.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Refactor Status command to use status instead of state and also display the new enabled property. Status and enabled are separated in intent. diff --git a/.changeset/spotty-clowns-draw.md b/.changeset/spotty-clowns-draw.md new file mode 100644 index 00000000..3eaac3f6 --- /dev/null +++ b/.changeset/spotty-clowns-draw.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Add support to withdrawal-config and way to setup claim-staging-period (Authority/Quorum only) when using the RUN command. diff --git a/.changeset/strong-eyes-glow.md b/.changeset/strong-eyes-glow.md new file mode 100644 index 00000000..fa1504b4 --- /dev/null +++ b/.changeset/strong-eyes-glow.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Remove --no-rollup flag pass down to the cartesi-machine as this flag does not exist on 0.19 and 0.20 versions. diff --git a/.changeset/weak-tigers-shine.md b/.changeset/weak-tigers-shine.md new file mode 100644 index 00000000..44c506f3 --- /dev/null +++ b/.changeset/weak-tigers-shine.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Update flashdrive label option from filename to data_filename. Also bump required-version for cartesi-machine tests to 0.20.0 diff --git a/apps/cli/package.json b/apps/cli/package.json index fd114141..0def09da 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -41,8 +41,7 @@ "yaml": "^2.8.2" }, "devDependencies": { - "@cartesi/devnet": "2.0.0-alpha.11", - "@cartesi/rollups": "2.2.0", + "@cartesi/devnet": "2.0.0-alpha.14", "@sunodo/wagmi-plugin-hardhat-deploy": "^0.4.0", "@types/bun": "^1.3.6", "@types/bytes": "^3.1.5", diff --git a/apps/cli/src/base.ts b/apps/cli/src/base.ts index 6fa43513..c3f637ff 100644 --- a/apps/cli/src/base.ts +++ b/apps/cli/src/base.ts @@ -22,9 +22,10 @@ import { etherPortalAddress, inputBoxAddress, selfHostedApplicationFactoryAddress, + testFungibleTokenAddress, testMultiTokenAddress, - testNftAddress, - testTokenAddress, + testNonFungibleTokenAddress, + testUsdWithdrawalOutputBuilderAddress, } from "./contracts.js"; import { getApplicationAddress, getForkChainId } from "./exec/rollups.js"; import type { PsResponse } from "./types/docker.js"; @@ -35,11 +36,18 @@ export const getContextPath = (...paths: string[]): string => { export const getMachineHash = (): Hash | undefined => { // read hash of the cartesi machine snapshot, if one exists - const hashPath = getContextPath("image", "hash"); + const hashPath = getContextPath("image", "hash_tree.sht"); if (fs.existsSync(hashPath)) { - const hash = fs.readFileSync(hashPath).toString("hex"); - if (isHash(`0x${hash}`)) { - return `0x${hash}`; + const fileBuffer = fs.readFileSync(hashPath); + const hashLength = 32; + // root hash is located at this offset (0x60) + const offset = 0x60; + + const hashBuffer = fileBuffer.subarray(offset, offset + hashLength); + const hash = `0x${hashBuffer.toString("hex")}`; + + if (isHash(hash)) { + return hash; } } return undefined; @@ -62,6 +70,22 @@ export const getProjectName = (options: { projectName?: string }) => { return options.projectName ?? path.basename(process.cwd()); }; +export type CartesiEnvironmentVariables = Record; +/** + * Generic function to get all environment variables that start with "CARTESI_" + * It is the responsibility of the caller to filter and use only the relevant variables. + * @returns A record of environment variables with keys starting with "CARTESI_" + */ +export function getCartesiEnvironmentVariables(): CartesiEnvironmentVariables { + const env: CartesiEnvironmentVariables = {}; + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith("CARTESI_") && value !== undefined) { + env[key] = value; + } + } + return env; +} + export type AddressBook = Record; export const getAddressBook = async (options: { @@ -99,9 +123,10 @@ export const getAddressBook = async (options: { // contracts that are present only on devnet state const devnetContracts: AddressBook = { - TestToken: testTokenAddress, - TestNFT: testNftAddress, + TestToken: testFungibleTokenAddress, + TestNFT: testNonFungibleTokenAddress, TestMultiToken: testMultiTokenAddress, + TestUsdWithdrawalOutputBuilder: testUsdWithdrawalOutputBuilderAddress, }; // contracts that are present on both devnet and live chains diff --git a/apps/cli/src/commands/deposit/erc20.ts b/apps/cli/src/commands/deposit/erc20.ts index 7b2c8f49..6ced70d4 100755 --- a/apps/cli/src/commands/deposit/erc20.ts +++ b/apps/cli/src/commands/deposit/erc20.ts @@ -15,7 +15,7 @@ import { getProjectName } from "../../base.js"; import { erc20PortalAbi, erc20PortalAddress, - testTokenAddress, + testFungibleTokenAddress, } from "../../contracts.js"; import { addressInput, @@ -68,7 +68,7 @@ const parseToken = async (options: { ? getAddress(options.token) : await addressInput({ message: "Token address", - default: testTokenAddress, + default: testFungibleTokenAddress, }); return readToken(testClient, address); diff --git a/apps/cli/src/commands/deposit/erc721.ts b/apps/cli/src/commands/deposit/erc721.ts index 412ee6c6..53567bb8 100755 --- a/apps/cli/src/commands/deposit/erc721.ts +++ b/apps/cli/src/commands/deposit/erc721.ts @@ -15,8 +15,8 @@ import { getProjectName } from "../../base.js"; import { erc721PortalAbi, erc721PortalAddress, - testNftAbi, - testNftAddress, + testNonFungibleTokenAbi, + testNonFungibleTokenAddress, } from "../../contracts.js"; import { addressInput, @@ -63,7 +63,7 @@ const parseToken = async (options: { ? getAddress(options.token) : await addressInput({ message: "Token address", - default: testNftAddress, + default: testNonFungibleTokenAddress, }); return readToken(testClient, address); @@ -100,7 +100,9 @@ export const createErc721Command = () => { token: options.token, }); const tokenAbi = - token.address === testNftAddress ? testNftAbi : erc721Abi; + token.address === testNonFungibleTokenAddress + ? testNonFungibleTokenAbi + : erc721Abi; // get dapp address from local node, or ask const application = await getInputApplicationAddress({ diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index d4e881b7..84b26f65 100755 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -15,8 +15,17 @@ import { http, numberToHex, } from "viem"; -import { getMachineHash, getProjectName } from "../base.js"; -import { DEFAULT_SDK_VERSION, PREFERRED_PORT } from "../config.js"; +import { + getApplicationConfig, + getMachineHash, + getProjectName, +} from "../base.js"; +import { nodeAllowedEnvironmentVariables } from "../compose/node.js"; +import { + DEFAULT_SDK_VERSION, + PREFERRED_PORT, + type WithdrawalConfig, +} from "../config.js"; import { AVAILABLE_SERVICES, deployApplication, @@ -45,8 +54,18 @@ const shell = async (options: { projectName: string; prt?: boolean; salt: number; + withdrawalConfig?: WithdrawalConfig; + claimStagingPeriod: number; }) => { - const { build, epochLength, log, projectName, prt } = options; + const { + build, + epochLength, + log, + projectName, + prt, + withdrawalConfig, + claimStagingPeriod, + } = options; let lastDeployment = options.deployment; let salt = options.salt; @@ -100,6 +119,8 @@ const shell = async (options: { projectName, prt, salt: numberToHex(salt++, { size: 32 }), + withdrawalConfig, + claimStagingPeriod, }); } @@ -137,8 +158,19 @@ const deploy = async (options: { projectName: string; prt?: boolean; salt: Hex; + withdrawalConfig?: WithdrawalConfig; + claimStagingPeriod: number; }) => { - const { consensus, epochLength, hash, projectName, prt, salt } = options; + const { + consensus, + epochLength, + hash, + projectName, + prt, + salt, + withdrawalConfig, + claimStagingPeriod, + } = options; // deploy application to node (onchain and offchain) const progress = ora( @@ -153,6 +185,8 @@ const deploy = async (options: { prt, salt, snapshotPath: "/var/lib/cartesi-rollups-node/snapshots/image", + withdrawalConfig, + claimStagingPeriod, }); progress.succeed( `${chalk.cyan(projectName)} machine hash is ${chalk.cyan(hash)}`, @@ -221,6 +255,11 @@ export const createRunCommand = () => { .default("latest"), ) .option("--dry-run", "show the docker compose configuration", false) + .option( + "--list-supported-variables", + "Returns JSON formatted information about the environment variables allowed and which service will use them.", + false, + ) .option("--fork-url ", "RPC URL to fork from") .addOption( new Option( @@ -243,6 +282,20 @@ export const createRunCommand = () => { .default(720), ) .option("-p, --port ", "port to listen on", Number) + .addOption( + new Option( + "--claim-staging-period ", + "claim staging period (in blocks). Number of blocks between a claim being submitted and accepted (Authority/Quorum Only)", + ) + .argParser(Number) + .default(0), + ) + .option( + "-c, --config ", + "Path to the configuration file (.toml)", + (value, prev) => prev.concat([value]), + ["cartesi.toml"], + ) .addOption( new Option( "--runtime-version ", @@ -274,10 +327,25 @@ export const createRunCommand = () => { runtimeVersion, services, verbose, + listSupportedVariables, + claimStagingPeriod, + config: configFiles, } = options; const progress = ora(); + if (listSupportedVariables) { + const allowedVarsByService = { + rollupsNode: nodeAllowedEnvironmentVariables, + }; + + // output the allowed environment variables by service in a JSON format and quit + process.stdout.write( + JSON.stringify(allowedVarsByService, null, 2), + ); + return; + } + if (defaultBlock !== "finalized") { console.warn( chalk.yellow( @@ -289,6 +357,9 @@ export const createRunCommand = () => { // project name explicitly defined or the current directory name const projectName = getProjectName(options); + // get application configuration (e.g. use withdrawal config if present) + const applicationConfig = getApplicationConfig(configFiles); + // resolve port number, using the first free port in a range, unless explicitly set const port = options.port || @@ -351,6 +422,8 @@ export const createRunCommand = () => { projectName, prt, salt: numberToHex(salt++, { size: 32 }), + claimStagingPeriod, + withdrawalConfig: applicationConfig?.withdrawalConfig, }); } else { console.warn( @@ -392,6 +465,8 @@ export const createRunCommand = () => { projectName, prt, salt, + claimStagingPeriod, + withdrawalConfig: applicationConfig?.withdrawalConfig, }); await shutdown(); } else { diff --git a/apps/cli/src/commands/shell.ts b/apps/cli/src/commands/shell.ts index 4f843664..9ffaa8c8 100755 --- a/apps/cli/src/commands/shell.ts +++ b/apps/cli/src/commands/shell.ts @@ -48,6 +48,7 @@ export const createShellCommand = () => { { cwd: destination, stdio: "inherit", + tty: true, }, ); } catch (error: unknown) { diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 1c0739e6..5ea37ee5 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -44,14 +44,17 @@ export const createStatusCommand = () => { } else { // print as a table const table = new Table({ - head: ["Machine", "Address", "State"], + head: ["Machine", "Address", "Status", "Enabled"], style: { border: [], head: [] }, }); table.push( ...deployments.map((deployment) => [ deployment.templateHash, deployment.address, - deployment.state, + deployment.status, + deployment.enabled + ? chalk.green("yes") + : chalk.red("no"), ]), ); console.log(table.toString()); diff --git a/apps/cli/src/compose/node.ts b/apps/cli/src/compose/node.ts index 9f1e8fb1..8d8f75a7 100644 --- a/apps/cli/src/compose/node.ts +++ b/apps/cli/src/compose/node.ts @@ -1,4 +1,5 @@ import { anvil } from "viem/chains"; +import type { CartesiEnvironmentVariables } from "../base.js"; import { daveAppFactoryAddress, inputBoxAddress, @@ -7,7 +8,7 @@ import { import type { ComposeFile, Config, Service } from "../types/compose.js"; import { DEFAULT_HEALTHCHECK } from "./common.js"; -type ServiceOptions = { +export type ServiceOptions = { cpus?: number; databaseHost?: string; databasePort?: number; @@ -19,6 +20,69 @@ type ServiceOptions = { mnemonic?: string; imageTag?: string; prt?: boolean; + cartesiEnvironmentVariables?: CartesiEnvironmentVariables; +}; + +/** + * A list of allowed environment variables + * that can be passed to the rollups node service. + * A number of environment variables are rule out to avoid confusion e.g. CORS, FEATURE Enablement, etc. + */ +export const nodeAllowedEnvironmentVariables = [ + "CARTESI_AUTH_MNEMONIC", + "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX", + "CARTESI_BLOCKCHAIN_DEFAULT_BLOCK", + "CARTESI_BLOCKCHAIN_HTTP_AUTHORIZATION", + "CARTESI_BLOCKCHAIN_HTTP_ENDPOINT", + "CARTESI_BLOCKCHAIN_ID", + "CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS", + "CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS", + "CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS", + "CARTESI_CONTRACTS_INPUT_BOX_ADDRESS", + "CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS", + "CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS", + "CARTESI_DATABASE_CONNECTION", + "CARTESI_LOG_LEVEL", + "CARTESI_LOG_LEVEL_ADVANCER", + "CARTESI_LOG_LEVEL_CLAIMER", + "CARTESI_LOG_LEVEL_EVM_READER", + "CARTESI_LOG_LEVEL_JSONRPC_API", + "CARTESI_LOG_LEVEL_PRT", + "CARTESI_LOG_LEVEL_VALIDATOR", + "CARTESI_JSONRPC_MACHINE_LOG_LEVEL", + "CARTESI_SNAPSHOTS_DIR", +] as const; + +type NodeAllowedEnvironmentVars = Partial< + Record<(typeof nodeAllowedEnvironmentVariables)[number], string> +>; + +/** + * Returns a subset of the provided environment variables that are allowed + * to be passed to the rollups node service. + * If no environment variables are provided, an empty object is returned. + * @param cartesiVars - An object containing environment variables to filter. + * @returns An object containing only the allowed environment variables. + * @param cartesiVars + * @returns + */ +export const getNodeAllowedVariables = ( + cartesiVars?: CartesiEnvironmentVariables, +): NodeAllowedEnvironmentVars => { + const allowedVars = nodeAllowedEnvironmentVariables.reduce( + (acc, variableName) => { + const value = cartesiVars?.[variableName]; + + if (value !== undefined && value !== null) { + acc[variableName] = value; + } + + return acc; + }, + {} as NodeAllowedEnvironmentVars, + ); + + return allowedVars; }; // Rollups Node service @@ -44,6 +108,34 @@ const service = (options: ServiceOptions): Service => { } } + const defaultVars = { + CARTESI_AUTH_MNEMONIC: mnemonic, + // First account generated by the devnet test mnemonic 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX: "0", + CARTESI_BLOCKCHAIN_DEFAULT_BLOCK: defaultBlock, + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: "http://anvil:8545", + CARTESI_BLOCKCHAIN_ID: anvil.id.toString(), + ...(chainDaveAppFactoryAddress && { + CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: + chainDaveAppFactoryAddress, + }), + CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: inputBoxAddress, + CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: + selfHostedApplicationFactoryAddress, + CARTESI_DATABASE_CONNECTION: `postgres://postgres:${databasePassword}@${databaseHost}:${databasePort}/rollupsdb?sslmode=disable`, + CARTESI_LOG_LEVEL: logLevel, + CARTESI_SNAPSHOTS_DIR: "/var/lib/cartesi-rollups-node/snapshots", + }; + + const hostVars = getNodeAllowedVariables( + options.cartesiEnvironmentVariables, + ); + + // Merge default and host environment variables, with host variables taking precedence + // further to the right of the parameter list the higher the precedence + // TODO: Here or somewhere else; Add other variables with precedence rules, e.g. command line > .toml > host environment > default + const environmentVariables = Object.assign({}, defaultVars, hostVars); + return { image: `cartesi/rollups-runtime:${imageTag}`, init: true, @@ -72,21 +164,7 @@ const service = (options: ServiceOptions): Service => { }, command: ["cartesi-rollups-node"], environment: { - CARTESI_AUTH_MNEMONIC: mnemonic, - CARTESI_BLOCKCHAIN_DEFAULT_BLOCK: defaultBlock, - CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: "http://anvil:8545", - CARTESI_BLOCKCHAIN_ID: anvil.id.toString(), - CARTESI_BLOCKCHAIN_WS_ENDPOINT: "ws://anvil:8545", - ...(chainDaveAppFactoryAddress && { - CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: - chainDaveAppFactoryAddress, - }), - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: inputBoxAddress, - CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: - selfHostedApplicationFactoryAddress, - CARTESI_DATABASE_CONNECTION: `postgres://postgres:${databasePassword}@${databaseHost}:${databasePort}/rollupsdb?sslmode=disable`, - CARTESI_LOG_LEVEL: logLevel, - CARTESI_SNAPSHOTS_DIR: "/var/lib/cartesi-rollups-node/snapshots", + ...environmentVariables, }, volumes: ["./.cartesi:/var/lib/cartesi-rollups-node/snapshots:ro"], }; @@ -101,10 +179,29 @@ http: routers: inspect_server: rule: "PathPrefix(\`/inspect\`)" + middlewares: + - "cors" service: inspect_server rpc_server: rule: "PathPrefix(\`/rpc\`)" + middlewares: + - "cors" service: rpc_server + middlewares: + cors: + headers: + accessControlAllowMethods: + - GET + - OPTIONS + - POST + accessControlAllowHeaders: + - "Origin" + - "Content-Type" + - "Accept" + accessControlAllowOriginList: + - "*" + accessControlMaxAge: 86400 + addVaryHeader: true services: inspect_server: loadBalancer: diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts index bf0397a4..28d88b7d 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config.ts @@ -1,6 +1,7 @@ import bytes from "bytes"; import { extname } from "node:path"; import { parse as parseToml, type TomlPrimitive } from "smol-toml"; +import { getAddress, isAddress, isHex, type Address } from "viem"; /** * Typed Errors @@ -41,12 +42,21 @@ export class InvalidBooleanValueError extends Error { } export class InvalidNumberValueError extends Error { - constructor(value: TomlPrimitive) { - super(`Invalid number value: ${value}`); + constructor(value: TomlPrimitive, key?: string) { + super(`Invalid number value: ${value}${key ? ` for key: ${key}` : ""}`); this.name = "InvalidNumberValueError"; } } +export class InvalidAddressValueError extends Error { + constructor(value: TomlPrimitive, key?: string) { + super( + `Invalid address value: ${value}${key ? ` for key: ${key}` : ""}`, + ); + this.name = "InvalidAddressValueError"; + } +} + export class InvalidBytesValueError extends Error { constructor(value: TomlPrimitive) { super(`Invalid bytes value: ${value}`); @@ -73,7 +83,7 @@ export class InvalidStringArrayError extends Error { */ const DEFAULT_FORMAT = "ext2"; const DEFAULT_RAM = "128Mi"; -export const DEFAULT_SDK_VERSION = "0.12.0-alpha.39"; +export const DEFAULT_SDK_VERSION = "0.12.0-alpha.41"; export const DEFAULT_SDK_IMAGE = "cartesi/sdk"; export const PREFERRED_PORT = 6751; @@ -144,7 +154,6 @@ export type MachineConfig = { bootargs: string[]; entrypoint?: string; maxMCycle?: bigint; // default given by cartesi-machine - noRollup?: boolean; // default given by cartesi-machine ramLength: string; ramImage?: string; // default given by cartesi-machine useDockerEnv: boolean; // inject docker image ENV into cartesi-machine ENV @@ -152,10 +161,24 @@ export type MachineConfig = { user?: string; // default given by cartesi-machine }; +/** + * Configuration for Emergercy-withdrawals that will be passed down to the + * cartesi-rollups-cli. This is a All or nothing kind of configuration. + * The properties are kept snake_case to match the expected input in the cartesi-rollups-cli. + */ +export type WithdrawalConfig = { + guardian: Address; + log2_leaves_per_account: number; + log2_max_num_of_accounts: number; + accounts_drive_start_index: number; + withdrawal_output_builder: Address; +}; + export type Config = { drives: Record; machine: MachineConfig; sdk: string; + withdrawalConfig?: WithdrawalConfig; }; type TomlTable = { [key: string]: TomlPrimitive }; @@ -175,7 +198,6 @@ export const defaultMachineConfig = (): MachineConfig => ({ bootargs: [], entrypoint: undefined, maxMCycle: undefined, - noRollup: undefined, ramLength: DEFAULT_RAM, useDockerEnv: true, useDockerWorkdir: true, @@ -186,6 +208,7 @@ export const defaultConfig = (): Config => ({ drives: { root: defaultRootDriveConfig() }, machine: defaultMachineConfig(), sdk: `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, + withdrawalConfig: undefined, }); const parseBoolean = (value: TomlPrimitive, defaultValue: boolean): boolean => { @@ -246,6 +269,35 @@ const parseRequiredString = (value: TomlPrimitive, key: string): string => { throw new InvalidStringValueError(value); }; +const parseRequiredNumber = (value: TomlPrimitive, key: string): number => { + if (value === undefined) { + throw new RequiredFieldError(key); + } + + if (typeof value === "number") { + return value; + } + + const val = + typeof value === "string" && isHex(value) ? parseInt(value, 16) : null; + + if (val !== null && !Number.isNaN(val)) { + return val; + } + + throw new InvalidNumberValueError(value, key); +}; + +const parseRequiredAddress = (value: TomlPrimitive, key: string): Address => { + if (value === undefined) { + throw new RequiredFieldError(key); + } + if (typeof value === "string" && isAddress(value)) { + return getAddress(value); + } + throw new InvalidAddressValueError(value, key); +}; + const parseOptionalString = (value: TomlPrimitive): string | undefined => { if (value === undefined) { return undefined; @@ -368,7 +420,6 @@ const parseMachine = (value: TomlPrimitive): MachineConfig => { bootargs: parseStringArray(toml.boot_args), entrypoint: parseOptionalString(toml.entrypoint), maxMCycle: parseOptionalNumber(toml.max_mcycle), - noRollup: parseBoolean(toml.no_rollup, false), ramLength: parseString(toml.ram_length, DEFAULT_RAM), ramImage: parseOptionalString(toml.ram_image), useDockerEnv: parseBoolean(toml.use_docker_env, true), @@ -495,6 +546,49 @@ const parseDrives = (config: TomlPrimitive): Record => { return drives; }; +const parseWithdrawalConfig = (config: TomlTable): WithdrawalConfig => { + return { + guardian: parseRequiredAddress(config.guardian, "guardian"), + log2_leaves_per_account: parseRequiredNumber( + config.log2_leaves_per_account, + "log2_leaves_per_account", + ), + log2_max_num_of_accounts: parseRequiredNumber( + config.log2_max_num_of_accounts, + "log2_max_num_of_accounts", + ), + accounts_drive_start_index: parseRequiredNumber( + config.accounts_drive_start_index, + "accounts_drive_start_index", + ), + withdrawal_output_builder: parseRequiredAddress( + config.withdrawal_output_builder, + "withdrawal_output_builder", + ), + }; +}; + +const parseOptionalWithdrawalConfig = ( + withdrawal: TomlPrimitive, +): WithdrawalConfig | undefined => { + if (withdrawal === undefined) { + return undefined; + } + + const config = (withdrawal as TomlTable).config; + + const isNotDefined = + config === undefined || + config === null || + Object.keys(config).length === 0; + + if (isNotDefined) { + return undefined; + } + + return parseWithdrawalConfig(config as TomlTable); +}; + export const parse = (str: string[]): Config => { let toml: TomlTable = {}; for (const s of str) { @@ -502,6 +596,7 @@ export const parse = (str: string[]): Config => { } const config: Config = { + withdrawalConfig: parseOptionalWithdrawalConfig(toml.withdrawal), drives: parseDrives(toml.drives), machine: parseMachine(toml.machine), sdk: parseString( diff --git a/apps/cli/src/exec/cartesi-machine.ts b/apps/cli/src/exec/cartesi-machine.ts index 063dcc23..2a27ab0f 100644 --- a/apps/cli/src/exec/cartesi-machine.ts +++ b/apps/cli/src/exec/cartesi-machine.ts @@ -5,7 +5,7 @@ import { type ExecaOptionsDockerFallback, } from "./util.js"; -export const requiredVersion = new Range("^0.19.0"); +export const requiredVersion = new Range("^0.20.0"); export const boot = ( args: readonly string[], diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index dc680972..7b800816 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -13,6 +13,7 @@ import { } from "viem"; import { stringify } from "yaml"; import { + getCartesiEnvironmentVariables, getContextPath, getMachineHash, getProjectName, @@ -28,6 +29,9 @@ import node from "../compose/node.js"; import passkey from "../compose/passkey.js"; import paymaster from "../compose/paymaster.js"; import proxy from "../compose/proxy.js"; +import type { WithdrawalConfig } from "../config.js"; + +type ApplicationStatus = "OK" | "FAILED" | "DIVERGED" | "CORRUPTED"; export type RollupsDeployment = { name: string; @@ -35,7 +39,8 @@ export type RollupsDeployment = { consensus: Address; templateHash: Hash; epochLength: number; - state: "ENABLED" | "DISABLED"; + status: ApplicationStatus; + enabled: boolean; }; type CliRollupsDeployment = { @@ -44,7 +49,8 @@ type CliRollupsDeployment = { iconsensus_address: string; template_hash: string; epoch_length: string; - state: string; + status: string; + enabled: boolean; }; type ComposeParams = { @@ -58,7 +64,8 @@ const parseDeployment = ( consensus: deployment.iconsensus_address as Address, epochLength: hexToNumber(deployment.epoch_length as Hex), name: deployment.name, - state: deployment.state as "ENABLED" | "DISABLED", + status: deployment.status as ApplicationStatus, + enabled: deployment.enabled, templateHash: deployment.template_hash as Hex, }); @@ -279,6 +286,9 @@ export const startEnvironment = async (options: { // local dev environment, we don't need security const databasePassword = "password"; + // Load all environment variables from the host that start with CARTESI_. + const hostVars = getCartesiEnvironmentVariables(); + const files = [ anvil({ blockTime, @@ -295,6 +305,7 @@ export const startEnvironment = async (options: { logLevel: verbose ? "debug" : "info", memory, prt, + cartesiEnvironmentVariables: hostVars, }), proxy({ imageTag: "v3.3.4", port }), ]; @@ -471,6 +482,8 @@ export const deployApplication = async (options: { prt?: boolean; salt?: Hex; snapshotPath: string; + withdrawalConfig?: WithdrawalConfig; + claimStagingPeriod: number; }): Promise => { const { consensus, @@ -480,6 +493,8 @@ export const deployApplication = async (options: { prt, salt, snapshotPath, + withdrawalConfig, + claimStagingPeriod, } = options; // app deploy args @@ -490,12 +505,28 @@ export const deployApplication = async (options: { } else { deployArgs.push("--epoch-length", epochLength.toString()); } + if (salt) { deployArgs.push("--salt", salt); } + if (prt) { deployArgs.push("--prt"); + } else { + // Claim staging period (Authority/Quorum only) + deployArgs.push( + "--claim-staging-period", + claimStagingPeriod.toString(), + ); } + + if (withdrawalConfig) { + deployArgs.push( + "--withdrawal-config", + JSON.stringify(withdrawalConfig), + ); + } + deployArgs.push("--json"); // deploy application diff --git a/apps/cli/src/exec/util.ts b/apps/cli/src/exec/util.ts index ff4e6530..f2ee451d 100644 --- a/apps/cli/src/exec/util.ts +++ b/apps/cli/src/exec/util.ts @@ -2,8 +2,8 @@ import { ExecaError, execa, type Options } from "execa"; import os from "node:os"; export type DockerFallbackOptions = - | { image: string; forceDocker: true } - | { image?: string; forceDocker?: false }; + | { image: string; forceDocker: true; tty?: boolean } + | { image?: string; forceDocker?: false; tty?: boolean }; /** * Calls execa and falls back to docker run if command (on the host) fails @@ -29,12 +29,14 @@ export const execaDockerFallback = async ( if (error instanceof ExecaError) { if (error.code === "ENOENT" && options.image) { const userInfo = os.userInfo(); + const optionalTTY = options.tty ? ["--tty"] : []; const dockerOpts = [ "--volume", `${options.cwd}:/work`, "--workdir", "/work", "--interactive", + ...optionalTTY, "--rm", "--user", `${userInfo.uid}:${userInfo.gid}`, diff --git a/apps/cli/src/machine.ts b/apps/cli/src/machine.ts index fc7b3f51..77d739df 100644 --- a/apps/cli/src/machine.ts +++ b/apps/cli/src/machine.ts @@ -5,7 +5,7 @@ import type { ExecaOptionsDockerFallback } from "./exec/util.js"; const flashDrive = (label: string, drive: DriveConfig): string => { const { format, mount, shared, user } = drive; const filename = `${label}.${format}`; - const vars = [`label:${label}`, `filename:${filename}`]; + const vars = [`label:${label}`, `data_filename:${filename}`]; if (mount !== undefined) { vars.push(`mount:${mount}`); } @@ -35,7 +35,6 @@ export const bootMachine = ( const { assertRollingTemplate, maxMCycle, - noRollup, ramLength, ramImage, useDockerEnv, @@ -108,9 +107,6 @@ export const bootMachine = ( if (bootOptions.interactive) { args.push("-it"); } - if (noRollup) { - args.push("--no-rollup"); - } if (maxMCycle) { args.push(`--max-mcycle=${maxMCycle.toString()}`); } diff --git a/apps/cli/tests/unit/compose/node.test.ts b/apps/cli/tests/unit/compose/node.test.ts new file mode 100644 index 00000000..5c48c2a3 --- /dev/null +++ b/apps/cli/tests/unit/compose/node.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from "bun:test"; +import buildNodeCompose, { + getNodeAllowedVariables, + nodeAllowedEnvironmentVariables, + type ServiceOptions, +} from "../../../src/compose/node.js"; +import { + inputBoxAddress, + selfHostedApplicationFactoryAddress, +} from "../../../src/contracts.js"; + +describe("Compose node service", () => { + describe("Node allowed environment variables", () => { + it("should match the exact fixed list of allowed variable names", () => { + expect(nodeAllowedEnvironmentVariables).toEqual([ + "CARTESI_AUTH_MNEMONIC", + "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX", + "CARTESI_BLOCKCHAIN_DEFAULT_BLOCK", + "CARTESI_BLOCKCHAIN_HTTP_AUTHORIZATION", + "CARTESI_BLOCKCHAIN_HTTP_ENDPOINT", + "CARTESI_BLOCKCHAIN_ID", + "CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS", + "CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS", + "CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS", + "CARTESI_CONTRACTS_INPUT_BOX_ADDRESS", + "CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS", + "CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS", + "CARTESI_DATABASE_CONNECTION", + "CARTESI_LOG_LEVEL", + "CARTESI_LOG_LEVEL_ADVANCER", + "CARTESI_LOG_LEVEL_CLAIMER", + "CARTESI_LOG_LEVEL_EVM_READER", + "CARTESI_LOG_LEVEL_JSONRPC_API", + "CARTESI_LOG_LEVEL_PRT", + "CARTESI_LOG_LEVEL_VALIDATOR", + "CARTESI_JSONRPC_MACHINE_LOG_LEVEL", + "CARTESI_SNAPSHOTS_DIR", + ]); + }); + }); + + describe("getNodeAllowedVariables function", () => { + it("should return an empty object when called with no arguments", () => { + expect(getNodeAllowedVariables()).toEqual({}); + }); + + it("should return an empty object when called with undefined", () => { + expect(getNodeAllowedVariables(undefined)).toEqual({}); + }); + + it("should return an empty object when input has no matching keys", () => { + const result = getNodeAllowedVariables({ + FOO: "bar", + SOME_OTHER_VAR: "value", + }); + expect(result).toEqual({}); + }); + + it("should return only allowed variables from the input", () => { + const result = getNodeAllowedVariables({ + CARTESI_LOG_LEVEL: "debug", + CARTESI_SNAPSHOTS_DIR: "/tmp/snapshots", + FOO: "should-be-excluded", + }); + + expect(result).toStrictEqual({ + CARTESI_LOG_LEVEL: "debug", + CARTESI_SNAPSHOTS_DIR: "/tmp/snapshots", + }); + expect(result).not.toHaveProperty("FOO"); + }); + + it("should exclude variables with undefined values", () => { + const input: Record = { + CARTESI_LOG_LEVEL: "info", + }; + // Simulate a key present but set to undefined via coercion + (input as Record).CARTESI_SNAPSHOTS_DIR = + undefined; + + const result = getNodeAllowedVariables(input); + expect(result).toEqual({ CARTESI_LOG_LEVEL: "info" }); + expect(result).not.toHaveProperty("CARTESI_SNAPSHOTS_DIR"); + }); + + it("should exclude variables with null values", () => { + const input: Record = { + CARTESI_LOG_LEVEL: "warn", + CARTESI_DATABASE_CONNECTION: null, + }; + + const result = getNodeAllowedVariables( + input as Record, + ); + expect(result).toEqual({ CARTESI_LOG_LEVEL: "warn" }); + expect(result).not.toHaveProperty("CARTESI_DATABASE_CONNECTION"); + }); + + it("should pass through all allowed variables when every allowed key is present", () => { + const input = Object.fromEntries( + nodeAllowedEnvironmentVariables.map((k) => [k, `value-${k}`]), + ); + const result = getNodeAllowedVariables(input); + expect(Object.keys(result).sort()).toEqual( + [...nodeAllowedEnvironmentVariables].sort(), + ); + }); + + it("should not include non-CARTESI_ keys even if they look similar", () => { + const result = getNodeAllowedVariables({ + CARTESI_LOG_LEVEL: "info", + XCARTESI_LOG_LEVEL: "debug", + CARTESI_LOG_LEVEL_EXTRA: "warn", // not in allow-list + }); + + expect(result).toEqual({ CARTESI_LOG_LEVEL: "info" }); + expect(result).not.toHaveProperty("XCARTESI_LOG_LEVEL"); + expect(result).not.toHaveProperty("CARTESI_LOG_LEVEL_EXTRA"); + }); + + it("should preserve original string values without transformation", () => { + const result = getNodeAllowedVariables({ + CARTESI_AUTH_MNEMONIC: "test test test junk", + CARTESI_BLOCKCHAIN_ID: "31337", + }); + expect(result.CARTESI_AUTH_MNEMONIC).toBe("test test test junk"); + expect(result.CARTESI_BLOCKCHAIN_ID).toBe("31337"); + }); + }); + + describe("Node service builder (buildNodeCompose)", () => { + const baseOptions: ServiceOptions = { + databasePassword: "secret", + databaseHost: "db", + databasePort: 5432, + defaultBlock: "latest", + imageTag: "latest", + logLevel: "info", + }; + + it("should return a compose file config with configs related to the rollups_node_proxy", () => { + const compose = buildNodeCompose(baseOptions); + + expect(compose.configs).toHaveProperty("rollups_node_proxy"); + const proxyConfig = compose.configs?.rollups_node_proxy; + expect(proxyConfig).toHaveProperty("content"); + }); + it("should return a compose file config with services including rollups_node and proxy services", () => { + const compose = buildNodeCompose(baseOptions); + expect(compose.services).toHaveProperty("rollups_node"); + expect(compose.services).toHaveProperty("proxy"); + }); + + it("should include the rollups-node config in proxy service", () => { + const compose = buildNodeCompose(baseOptions); + const proxyConfigs = compose.services?.proxy?.configs ?? []; + + expect(proxyConfigs).toHaveLength(1); + expect(proxyConfigs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: "rollups_node_proxy", + target: "/etc/traefik/conf.d/rollups-node.yaml", + }), + ]), + ); + }); + + it("should set the rollups_node image using the provided imageTag", () => { + const compose = buildNodeCompose({ + ...baseOptions, + imageTag: "1.2.3", + }); + expect(compose.services?.rollups_node?.image).toBe( + "cartesi/rollups-runtime:1.2.3", + ); + }); + + it("should default to latest image tag when imageTag is omitted", () => { + const compose = buildNodeCompose(baseOptions); + expect(compose.services?.rollups_node?.image).toBe( + "cartesi/rollups-runtime:latest", + ); + }); + + it("should pass sensible environment variables defaults for the rollups_node service", () => { + const compose = buildNodeCompose(baseOptions); + + const env = compose.services?.rollups_node?.environment ?? {}; + + expect(Object.keys(env)).toHaveLength(10); + + expect(env).toHaveProperty( + "CARTESI_AUTH_MNEMONIC", + "test test test test test test test test test test test junk", + ); + + expect(env).toHaveProperty( + "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX", + "0", + ); + expect(env).toHaveProperty( + "CARTESI_BLOCKCHAIN_DEFAULT_BLOCK", + "latest", + ); + expect(env).toHaveProperty( + "CARTESI_BLOCKCHAIN_HTTP_ENDPOINT", + "http://anvil:8545", + ); + expect(env).toHaveProperty("CARTESI_BLOCKCHAIN_ID", "31337"); + expect(env).toHaveProperty( + "CARTESI_CONTRACTS_INPUT_BOX_ADDRESS", + inputBoxAddress, + ); + expect(env).toHaveProperty( + "CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS", + selfHostedApplicationFactoryAddress, + ); + expect(env).toHaveProperty( + "CARTESI_DATABASE_CONNECTION", + "postgres://postgres:secret@db:5432/rollupsdb?sslmode=disable", + ); + expect(env).toHaveProperty("CARTESI_LOG_LEVEL", "info"); + expect(env).toHaveProperty( + "CARTESI_SNAPSHOTS_DIR", + "/var/lib/cartesi-rollups-node/snapshots", + ); + }); + + it("should pass cartesiEnvironmentVariables that are in the allow-list to the service", () => { + const compose = buildNodeCompose({ + ...baseOptions, + cartesiEnvironmentVariables: { + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX: "5", + CARTESI_LOG_LEVEL: "debug", + CARTESI_SNAPSHOTS_DIR: "/tmp/snapshots", + NOT_ALLOWED: "should-be-dropped", + }, + }); + const env = compose.services?.rollups_node?.environment ?? {}; + + expect(env).toHaveProperty( + "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX", + "5", + ); + expect(env).toHaveProperty("CARTESI_LOG_LEVEL", "debug"); + expect(env).toHaveProperty( + "CARTESI_SNAPSHOTS_DIR", + "/tmp/snapshots", + ); + expect(env).not.toHaveProperty("NOT_ALLOWED"); + }); + + it("should override default env vars with allowed-cartesi-environment-variables that are provided", () => { + const compose = buildNodeCompose({ + ...baseOptions, + logLevel: "info", + cartesiEnvironmentVariables: { + CARTESI_LOG_LEVEL: "warn", + }, + }); + const env = compose.services?.rollups_node?.environment ?? {}; + expect(env).toHaveProperty("CARTESI_LOG_LEVEL", "warn"); + }); + + it("should build the database connection string from provided property", () => { + const compose = buildNodeCompose({ + databasePassword: "specialsecret", + }); + const env = compose.services?.rollups_node?.environment ?? {}; + expect(env).toHaveProperty( + "CARTESI_DATABASE_CONNECTION", + "postgres://postgres:specialsecret@database:5432/rollupsdb?sslmode=disable", + ); + }); + }); +}); diff --git a/apps/cli/tests/unit/config.test.ts b/apps/cli/tests/unit/config.test.ts index d57ace91..2d32e92b 100644 --- a/apps/cli/tests/unit/config.test.ts +++ b/apps/cli/tests/unit/config.test.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import { defaultConfig, defaultMachineConfig, + InvalidAddressValueError, InvalidBooleanValueError, InvalidBuilderError, InvalidBytesValueError, @@ -99,14 +100,14 @@ shared = true`, describe("when parsing [machine]", () => { const config = ` [machine] - no_rollup = true + use_docker_env = true `; it("machine-config", () => { expect(parse([config])).toEqual({ ...defaultConfig(), machine: { ...defaultMachineConfig(), - noRollup: true, + useDockerEnv: true, }, }); }); @@ -128,13 +129,251 @@ shared = true`, ...defaultConfig(), machine: { ...defaultMachineConfig(), - noRollup: true, + useDockerEnv: true, entrypoint: "echo 'Hello, World!'", }, }); }); }); + /** + * [withdrawal] + */ + describe("when parsing [withdrawal.config]", () => { + it("should parse a valid withdrawal config", () => { + const config = ` + [withdrawal.config] + guardian = "0x1111111111111111111111111111111111111111" + log2_leaves_per_account = 0 + log2_max_num_of_accounts = 20 + accounts_drive_start_index = 33554432 + withdrawal_output_builder = "0x2222222222222222222222222222222222222222" + `; + expect(parse([config])).toEqual({ + ...defaultConfig(), + withdrawalConfig: { + guardian: "0x1111111111111111111111111111111111111111", + log2_leaves_per_account: 0, + log2_max_num_of_accounts: 20, + accounts_drive_start_index: 33554432, + withdrawal_output_builder: + "0x2222222222222222222222222222222222222222", + }, + }); + }); + + it("should parse a valid withdrawal config that uses hex instead of decimal for numbers", () => { + const config = ` + [withdrawal.config] + guardian = "0x1111111111111111111111111111111111111111" + log2_leaves_per_account = 0x0 + log2_max_num_of_accounts = 0x14 + accounts_drive_start_index = 0x2000000 + withdrawal_output_builder = "0x2222222222222222222222222222222222222222" + `; + expect(parse([config])).toEqual({ + ...defaultConfig(), + withdrawalConfig: { + guardian: "0x1111111111111111111111111111111111111111", + log2_leaves_per_account: 0, + log2_max_num_of_accounts: 20, + accounts_drive_start_index: 33554432, + withdrawal_output_builder: + "0x2222222222222222222222222222222222222222", + }, + }); + }); + + it("should parse a valid withdrawal config even when using quoted hex for the numbers", () => { + const config = ` + [withdrawal.config] + guardian = "0x1111111111111111111111111111111111111111" + log2_leaves_per_account = "0x0" + log2_max_num_of_accounts = "0x14" + accounts_drive_start_index = "0x2000000" + withdrawal_output_builder = "0x2222222222222222222222222222222222222222" + `; + + expect(parse([config])).toEqual({ + ...defaultConfig(), + withdrawalConfig: { + guardian: "0x1111111111111111111111111111111111111111", + log2_leaves_per_account: 0, + log2_max_num_of_accounts: 20, + accounts_drive_start_index: 33554432, + withdrawal_output_builder: + "0x2222222222222222222222222222222222222222", + }, + }); + }); + + it("should return undefined when [withdrawal.config] is not defined", () => { + const config = ``; + expect(parse([config])).toEqual({ + ...defaultConfig(), + withdrawalConfig: undefined, + }); + }); + + it("should return undefined when [withdrawal.config] is empty", () => { + const config = ` + [withdrawal.config] + `; + + expect(parse([config])).toEqual({ + ...defaultConfig(), + withdrawalConfig: undefined, + }); + }); + + it("should fail when missing guardian field", () => { + const config = ` + [withdrawal.config] + log2_leaves_per_account = 0 + log2_max_num_of_accounts = 20 + accounts_drive_start_index = 33554432 + withdrawal_output_builder = "0x2222222222222222222222222222222222222222" + `; + expect(() => parse([config])).toThrowError( + new RequiredFieldError("guardian"), + ); + }); + + it("should fail when missing withdrawal_output_builder field", () => { + const config = ` + [withdrawal.config] + guardian = "0x1111111111111111111111111111111111111111" + log2_leaves_per_account = 0 + log2_max_num_of_accounts = 20 + accounts_drive_start_index = 33554432 + `; + expect(() => parse([config])).toThrowError( + new RequiredFieldError("withdrawal_output_builder"), + ); + }); + + it("should fail when missing log2_leaves_per_account field", () => { + const config = ` + [withdrawal.config] + guardian = "0x1111111111111111111111111111111111111111" + log2_max_num_of_accounts = 20 + accounts_drive_start_index = 33554432 + withdrawal_output_builder = "0x2222222222222222222222222222222222222222" + `; + expect(() => parse([config])).toThrowError( + new RequiredFieldError("log2_leaves_per_account"), + ); + }); + + it("should fail when missing log2_max_num_of_accounts field", () => { + const config = ` + [withdrawal.config] + guardian = "0x1111111111111111111111111111111111111111" + log2_leaves_per_account = 0 + accounts_drive_start_index = 33554432 + withdrawal_output_builder = "0x2222222222222222222222222222222222222222222" + `; + expect(() => parse([config])).toThrowError( + new RequiredFieldError("log2_max_num_of_accounts"), + ); + }); + + it("should fail when missing accounts_drive_start_index field", () => { + const config = ` + [withdrawal.config] + guardian = "0x1111111111111111111111111111111111111111" + log2_leaves_per_account = 0 + log2_max_num_of_accounts = 20 + withdrawal_output_builder = "0x2222222222222222222222222222222222222222" + `; + expect(() => parse([config])).toThrowError( + new RequiredFieldError("accounts_drive_start_index"), + ); + }); + + it("should fail when guardian is not a valid address", () => { + const config = ` + [withdrawal.config] + guardian = "invalid_address" + log2_leaves_per_account = 0 + log2_max_num_of_accounts = 20 + accounts_drive_start_index = 33554432 + withdrawal_output_builder = "0x2222222222222222222222222222222222222222" + `; + expect(() => parse([config])).toThrowError( + new InvalidAddressValueError("invalid_address", "guardian"), + ); + }); + + it("should fail when withdrawal_output_builder is not a valid address", () => { + const config = ` + [withdrawal.config] + guardian = "0x1111111111111111111111111111111111111111" + log2_leaves_per_account = 0 + log2_max_num_of_accounts = 20 + accounts_drive_start_index = 33554432 + withdrawal_output_builder = "invalid_address" + `; + expect(() => parse([config])).toThrowError( + new InvalidAddressValueError( + "invalid_address", + "withdrawal_output_builder", + ), + ); + }); + + it("should fail when log2_leaves_per_account is not a number", () => { + const config = ` + [withdrawal.config] + guardian = "0x1111111111111111111111111111111111111111" + log2_leaves_per_account = "not_a_number" + log2_max_num_of_accounts = 20 + accounts_drive_start_index = 33554432 + withdrawal_output_builder = "0x2222222222222222222222222222222222222222" + `; + expect(() => parse([config])).toThrowError( + new InvalidNumberValueError( + "not_a_number", + "log2_leaves_per_account", + ), + ); + }); + + it("should fail when log2_max_num_of_accounts is not a number", () => { + const config = ` + [withdrawal.config] + guardian = "0x1111111111111111111111111111111111111111" + log2_leaves_per_account = 0 + log2_max_num_of_accounts = "not_a_number" + accounts_drive_start_index = 33554432 + withdrawal_output_builder = "0x2222222222222222222222222222222222222222" + `; + expect(() => parse([config])).toThrowError( + new InvalidNumberValueError( + "not_a_number", + "log2_max_num_of_accounts", + ), + ); + }); + + it("should fail when accounts_drive_start_index is not a number", () => { + const config = ` + [withdrawal.config] + guardian = "0x1111111111111111111111111111111111111111" + log2_leaves_per_account = 0 + log2_max_num_of_accounts = 20 + accounts_drive_start_index = "not_a_number" + withdrawal_output_builder = "0x2222222222222222222222222222222222222222" + `; + expect(() => parse([config])).toThrowError( + new InvalidNumberValueError( + "not_a_number", + "accounts_drive_start_index", + ), + ); + }); + }); + /** * [drives] */ @@ -205,9 +444,9 @@ shared = true`, */ describe("when parsing fields types", () => { it("should fail for invalid boolean value", () => { - expect(() => parse(["[machine]\nno_rollup = 42"])).toThrowError( - new InvalidBooleanValueError(42), - ); + expect(() => + parse(["[machine]\nuse_docker_env = 42"]), + ).toThrowError(new InvalidBooleanValueError(42)); }); it("should fail for invalid number value", () => { diff --git a/apps/cli/tests/unit/config/fixtures/full.toml b/apps/cli/tests/unit/config/fixtures/full.toml index 9369a023..fa40fabb 100644 --- a/apps/cli/tests/unit/config/fixtures/full.toml +++ b/apps/cli/tests/unit/config/fixtures/full.toml @@ -6,7 +6,6 @@ # entrypoint = "/usr/local/bin/app" # final_hash = true # max_mcycle = 0 -# no_rollup = false # ram_image = "/usr/share/cartesi-machine/images/linux.bin" # directory inside SDK image # ram_length = "128Mi" # use_docker_env = true @@ -44,3 +43,10 @@ # builder = "none" # filename = "./games/doom.sqfs" # mount = "/usr/local/games/doom" + +# [withdrawal.config] +# guardian = "0x1111111111111111111111111111111111111111" +# log2_leaves_per_account = 0 +# log2_max_num_of_accounts = 20 +# accounts_drive_start_index = 33554432 +# withdrawal_output_builder = "0x2222222222222222222222222222222222222222" \ No newline at end of file diff --git a/apps/cli/tests/unit/config/fixtures/withdrawal/config.toml b/apps/cli/tests/unit/config/fixtures/withdrawal/config.toml new file mode 100644 index 00000000..89f74a4f --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/withdrawal/config.toml @@ -0,0 +1,16 @@ +# example of a valid withdrawal configuration. + +[withdrawal.config] +guardian = "0x1111111111111111111111111111111111111111" +log2_leaves_per_account = 0 +log2_max_num_of_accounts = 20 +accounts_drive_start_index = 33554432 +withdrawal_output_builder = "0x2222222222222222222222222222222222222222" + +# Also valid if numbers are in hex format. +# [withdrawal.config] +# guardian = "0x1111111111111111111111111111111111111111" +# log2_leaves_per_account = 0x0 +# log2_max_num_of_accounts = 0x14 +# accounts_drive_start_index = 0x2000000 +# withdrawal_output_builder = "0x2222222222222222222222222222222222222222" \ No newline at end of file diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 00000000..ff3705f9 --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["bun"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true + }, + "include": ["src", "tests", "*.ts"] +} diff --git a/bun.lock b/bun.lock index 34fecd7b..f52fbd25 100644 --- a/bun.lock +++ b/bun.lock @@ -44,8 +44,7 @@ "yaml": "^2.8.2", }, "devDependencies": { - "@cartesi/devnet": "2.0.0-alpha.11", - "@cartesi/rollups": "2.2.0", + "@cartesi/devnet": "2.0.0-alpha.14", "@sunodo/wagmi-plugin-hardhat-deploy": "^0.4.0", "@types/bun": "^1.3.6", "@types/bytes": "^3.1.5", @@ -67,7 +66,7 @@ }, "packages/devnet": { "name": "@cartesi/devnet", - "version": "2.0.0-alpha.11", + "version": "2.0.0-alpha.14", "devDependencies": { "@types/bun": "^1.3.9", "@types/fs-extra": "^11.0.4", @@ -142,8 +141,6 @@ "@cartesi/mock-verifying-paymaster": ["@cartesi/mock-verifying-paymaster@workspace:packages/mock-verifying-paymaster"], - "@cartesi/rollups": ["@cartesi/rollups@2.2.0", "", {}, "sha512-I4mC6UBvLmz52d+jSHvbWXh9VLSprWYRcT3VoufBypA7P66sXU7XKoOgHiiBzoVp/KX4lL3agv3Fx0rxE+nvWg=="], - "@cartesi/sdk": ["@cartesi/sdk@workspace:packages/sdk"], "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.0.14", "", { "dependencies": { "@changesets/config": "3.1.2", "@changesets/get-version-range-type": "0.4.0", "@changesets/git": "3.0.4", "@changesets/should-skip-package": "0.1.2", "@changesets/types": "6.1.0", "@manypkg/get-packages": "1.1.3", "detect-indent": "6.1.0", "fs-extra": "7.0.1", "lodash.startcase": "4.4.0", "outdent": "0.5.0", "prettier": "2.8.8", "resolve-from": "5.0.0", "semver": "7.7.2" } }, "sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA=="], @@ -1168,7 +1165,7 @@ "zod-validation-error": ["zod-validation-error@3.5.4", "", { "peerDependencies": { "zod": "^3.24.4" } }, "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw=="], - "@cartesi/cli/@cartesi/devnet": ["@cartesi/devnet@2.0.0-alpha.11", "", {}, "sha512-IMpYCuLwXa+dIJYcA0IQWfrHiVXAbkDXtf9xzVHGo6iKzR1skhw6x6cv5YqgJrXlJzna2cT3zKG/Nd4VbQTJvg=="], + "@cartesi/cli/@cartesi/devnet": ["@cartesi/devnet@2.0.0-alpha.14", "", {}, "sha512-BPcFh1NBauZlpoL6HedBL88xvi5D+PweAUlxKSxNEiBCE8sJDx8TaTiwKtnSH27l6r4NuuZTeClgWBOq8e+Taw=="], "@cartesi/mock-verifying-paymaster/viem": ["viem@2.18.4", "", { "dependencies": { "@adraffy/ens-normalize": "1.10.0", "@noble/curves": "1.4.0", "@noble/hashes": "1.4.0", "@scure/bip32": "1.4.0", "@scure/bip39": "1.3.0", "abitype": "1.0.5", "isows": "1.0.4", "webauthn-p256": "0.0.5", "ws": "8.17.1" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-JGdN+PgBnZMbm7fc9o0SfHvL0CKyfrlhBUtaz27V+PeHO43Kgc9Zd4WyIbM8Brafq4TvVcnriRFW/FVGOzwEJw=="], diff --git a/packages/devnet/build.ts b/packages/devnet/build.ts index ae14a3c0..b4ce2dc2 100644 --- a/packages/devnet/build.ts +++ b/packages/devnet/build.ts @@ -2,6 +2,7 @@ import { semver } from "bun"; import { cpSync, existsSync, readdirSync } from "fs-extra"; import { Listr, type ListrTask } from "listr2"; import * as path from "node:path"; +import type { Abi } from "viem"; import { arbitrum, arbitrumSepolia, @@ -56,7 +57,7 @@ const dependencies: ListrTask[] = [ task: async () => await downloadAndExtract(file), })); -type ContractDeployments = Record; +type ContractDeployments = Record; /** * Collect contracts from deployments, objects keyed by contractName, with abi and address @@ -100,7 +101,7 @@ const collectContracts = async (dir: string): Promise => { contracts[contractName] = { abi, address }; return contracts; }, - {} as Record, + {} as Record, ); };