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/fine-friends-rhyme.md b/.changeset/fine-friends-rhyme.md new file mode 100644 index 00000000..588606fd --- /dev/null +++ b/.changeset/fine-friends-rhyme.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Improve validation when cli is used with fork-url by checking in the network if it is deployed and also based on ABI if a method can be called. 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..ea8f2021 --- /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. 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/rotten-hairs-beam.md b/.changeset/rotten-hairs-beam.md new file mode 100644 index 00000000..97e99a10 --- /dev/null +++ b/.changeset/rotten-hairs-beam.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Bump rollups-explorer to version 2.0.0-alpha.3 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/.github/workflows/cli.yaml b/.github/workflows/cli.yaml index 2f1701b2..ba130cf8 100644 --- a/.github/workflows/cli.yaml +++ b/.github/workflows/cli.yaml @@ -22,6 +22,12 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up QEMU + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 - name: Install Foundry 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..f567856b 100644 --- a/apps/cli/src/base.ts +++ b/apps/cli/src/base.ts @@ -10,6 +10,7 @@ import { isHash, zeroHash, } from "viem"; +import { foundry } from "viem/chains"; import { type Config, parse } from "./config.js"; import { applicationFactoryAddress, @@ -22,25 +23,25 @@ import { etherPortalAddress, inputBoxAddress, selfHostedApplicationFactoryAddress, + testFungibleTokenAddress, testMultiTokenAddress, - testNftAddress, - testTokenAddress, + testNonFungibleTokenAddress, + testUsdWithdrawalOutputBuilderAddress, } from "./contracts.js"; -import { getApplicationAddress, getForkChainId } from "./exec/rollups.js"; +import { cartesiMachineStoredHash } from "./exec"; +import { getApplicationAddress, getForkConfig } from "./exec/rollups.js"; import type { PsResponse } from "./types/docker.js"; +import { assertForkConfig } from "./validations.js"; export const getContextPath = (...paths: string[]): string => { return path.join(".cartesi", ...paths); }; -export const getMachineHash = (): Hash | undefined => { +export const getMachineHash = async (): Promise => { // read hash of the cartesi machine snapshot, if one exists - const hashPath = getContextPath("image", "hash"); - if (fs.existsSync(hashPath)) { - const hash = fs.readFileSync(hashPath).toString("hex"); - if (isHash(`0x${hash}`)) { - return `0x${hash}`; - } + const imagePath = getContextPath("image"); + if (fs.existsSync(imagePath)) { + return await cartesiMachineStoredHash.computeHash(imagePath); } return undefined; }; @@ -62,24 +63,39 @@ 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: { projectName?: string; }): Promise => { - const forkChainId = await getForkChainId(options); + const forkConfig = await getForkConfig(options); const applicationAddress = await getApplicationAddress(options); + if (forkConfig) { + await assertForkConfig(forkConfig, { includePRT: true }); + } + + const chainId = forkConfig?.chainId ?? foundry.id; + // this contract has different addresses on each of the supported chains const chainDaveAppFactoryAddress = - forkChainId !== undefined - ? daveAppFactoryAddress[ - forkChainId as keyof typeof daveAppFactoryAddress - ] - : daveAppFactoryAddress[31337]; - if (!chainDaveAppFactoryAddress) { - throw new Error(`Unsupported fork chain ${forkChainId}`); - } + daveAppFactoryAddress[chainId as keyof typeof daveAppFactoryAddress]; // contracts that are present only on live chains, with equal addresses on all of them const forkContracts: AddressBook = { @@ -99,9 +115,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 @@ -120,7 +137,7 @@ export const getAddressBook = async (options: { // gather all contracts, depending whether is fork or devnet const contracts: AddressBook = - forkChainId !== undefined + forkConfig !== undefined ? { ...commonContracts, ...forkContracts, 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/hash.ts b/apps/cli/src/commands/hash.ts index 8fd80462..54ccb239 100755 --- a/apps/cli/src/commands/hash.ts +++ b/apps/cli/src/commands/hash.ts @@ -9,7 +9,7 @@ export const createHashCommand = () => { ) .option("--json", "Format output as json.") .action(async ({ json }, command) => { - const hash = getMachineHash(); + const hash = await getMachineHash(); if (hash) { if (!json) { console.log( diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index d4e881b7..3ff5d45f 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, @@ -28,12 +37,8 @@ import { waitHealthyEnvironment, } from "../exec/rollups.js"; import { keySelect } from "../prompts.js"; - -export type ForkConfig = { - blockNumber?: bigint; - chainId: number; - url: string; -}; +import type { ForkConfig } from "../types/chain.js"; +import { assertForkConfig } from "../validations.js"; const commaSeparatedList = (value: string) => value.split(","); @@ -45,8 +50,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; @@ -88,7 +103,7 @@ const shell = async (options: { await build?.parseAsync([], { from: "user" }); // redeploy - const hash = getMachineHash(); + const hash = await getMachineHash(); if (hash) { if (lastDeployment) { await undeploy({ projectName }); @@ -100,6 +115,8 @@ const shell = async (options: { projectName, prt, salt: numberToHex(salt++, { size: 32 }), + withdrawalConfig, + claimStagingPeriod, }); } @@ -137,8 +154,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 +181,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 +251,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 +278,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 +323,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 +353,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 || @@ -299,6 +366,10 @@ export const createRunCommand = () => { // configure optional anvil fork const forkConfig = await configureFork(options); + if (forkConfig) { + await assertForkConfig(forkConfig, { includePRT: prt }); + } + // if TTY is not attached, run on foreground (not detached) const detach = process.stdin.isTTY; @@ -343,7 +414,7 @@ export const createRunCommand = () => { // deploy the application let deployment: RollupsDeployment | undefined; let salt = 0; - const hash = getMachineHash(); + const hash = await getMachineHash(); if (hash) { deployment = await deploy({ epochLength, @@ -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/anvil.ts b/apps/cli/src/compose/anvil.ts index 4d128979..c1607fea 100644 --- a/apps/cli/src/compose/anvil.ts +++ b/apps/cli/src/compose/anvil.ts @@ -1,4 +1,4 @@ -import type { ForkConfig } from "../commands/run.js"; +import type { ForkConfig } from "../types/chain.js"; import type { ComposeFile, Config, Service } from "../types/compose.js"; import { DEFAULT_HEALTHCHECK } from "./common.js"; diff --git a/apps/cli/src/compose/node.ts b/apps/cli/src/compose/node.ts index 9f1e8fb1..91b540ed 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 @@ -37,13 +101,39 @@ const service = (options: ServiceOptions): Service => { 31337) as keyof typeof daveAppFactoryAddress; let chainDaveAppFactoryAddress: string | undefined; + if (prt) { chainDaveAppFactoryAddress = daveAppFactoryAddress[chainId]; - if (!chainDaveAppFactoryAddress) { - throw new Error(`Unsupported fork chain ${chainId}`); - } } + 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 +162,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 +177,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/errors/ForkChainValidationError.ts b/apps/cli/src/errors/ForkChainValidationError.ts new file mode 100644 index 00000000..91e07a0b --- /dev/null +++ b/apps/cli/src/errors/ForkChainValidationError.ts @@ -0,0 +1,8 @@ +export default class ForkChainValidationError extends Error { + constructor(chainId: number, extraMessage?: string) { + super( + `An error happened during validation of fork chain ${chainId}${extraMessage ? `:\n${extraMessage}` : ""}`, + ); + this.name = "ForkChainValidationError"; + } +} diff --git a/apps/cli/src/errors/UnsupportedForkChainError.ts b/apps/cli/src/errors/UnsupportedForkChainError.ts new file mode 100644 index 00000000..77551776 --- /dev/null +++ b/apps/cli/src/errors/UnsupportedForkChainError.ts @@ -0,0 +1,8 @@ +export default class UnsupportedForkChainError extends Error { + constructor(chainId: number, extraMessage?: string) { + super( + `Unsupported fork chain ${chainId}${extraMessage ? `:\n${extraMessage}` : ""}`, + ); + this.name = "UnsupportedForkChainError"; + } +} diff --git a/apps/cli/src/exec/cartesi-machine-stored-hash.ts b/apps/cli/src/exec/cartesi-machine-stored-hash.ts new file mode 100644 index 00000000..6c360f3f --- /dev/null +++ b/apps/cli/src/exec/cartesi-machine-stored-hash.ts @@ -0,0 +1,43 @@ +import { isHash, type Hash } from "viem"; +import { DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION } from "../config.js"; +import { execaDockerFallback, type DockerFallbackOptions } from "./util.js"; + +type ComputeHashOptions = { cwd?: string } & DockerFallbackOptions; + +/** + * + * @param machineDir + * @param options + * @returns + */ +export const computeHash = async ( + machineDir: string, + options?: ComputeHashOptions, +): Promise => { + const defaultImage = `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`; + const execaOptions = Object.assign( + {}, + { image: defaultImage, cwd: process.cwd() }, + options, + ); + + try { + const { stdout } = await execaDockerFallback( + "cartesi-machine-stored-hash", + [machineDir], + execaOptions, + ); + + if (undefined !== stdout) { + const hash = `0x${stdout.toString().trim()}`; + + if (isHash(hash)) { + return hash; + } + } + + return undefined; + } catch { + return undefined; + } +}; 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/index.ts b/apps/cli/src/exec/index.ts index 897af549..f95d63da 100644 --- a/apps/cli/src/exec/index.ts +++ b/apps/cli/src/exec/index.ts @@ -1,3 +1,4 @@ +export * as cartesiMachineStoredHash from "./cartesi-machine-stored-hash.js"; export * as cartesiMachine from "./cartesi-machine.js"; export * as genext2fs from "./genext2fs.js"; export * as mksquashfs from "./mksquashfs.js"; diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index dc680972..64392327 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -13,12 +13,12 @@ import { } from "viem"; import { stringify } from "yaml"; import { + getCartesiEnvironmentVariables, getContextPath, getMachineHash, getProjectName, getServiceHealth, } from "../base.js"; -import type { ForkConfig } from "../commands/run.js"; import anvil from "../compose/anvil.js"; import { concat } from "../compose/builder.js"; import bundler from "../compose/bundler.js"; @@ -28,6 +28,10 @@ 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"; +import type { ForkConfig } from "../types/chain.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, }); @@ -85,7 +92,7 @@ export const getDeployments = async ( export const getApplicationDeployment = async ( options: ComposeParams, ): Promise => { - const machineHash = getMachineHash(); + const machineHash = await getMachineHash(); if (!machineHash) { return undefined; } @@ -104,21 +111,29 @@ export const getApplicationAddress = async (options: { }; /** - * Get anvil node configuration and query the chainId of its fork + * Get the fork configuration of anvil node, + * if it is configured with a forkUrl, it will query the chainId of the fork + * and return it along with the forkUrl and forkBlockNumber * @param options projectName - * @returns chainId of anvil fork + * @returns fork configuration of anvil node, or undefined if it is not configured with a forkUrl */ -export const getForkChainId = async (options: { +export const getForkConfig = async (options: { projectName?: string; -}): Promise => { +}): Promise => { const projectName = getProjectName(options ?? {}); try { const nodeInfo = await getAnvilNodeInfo({ projectName }); const forkUrl = nodeInfo?.forkConfig?.forkUrl; + const blockNumber = nodeInfo?.forkConfig?.forkBlockNumber; if (forkUrl) { // if anvil is configured with a forkUrl, connect to it and query the chainId const client = createPublicClient({ transport: http(forkUrl) }); - return client.getChainId(); + const chainId = await client.getChainId(); + return { + chainId, + url: forkUrl, + blockNumber: blockNumber ? BigInt(blockNumber) : undefined, + }; } } catch { // service may not be running, just return as there is no fork @@ -279,6 +294,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 +313,7 @@ export const startEnvironment = async (options: { logLevel: verbose ? "debug" : "info", memory, prt, + cartesiEnvironmentVariables: hostVars, }), proxy({ imageTag: "v3.3.4", port }), ]; @@ -302,7 +321,7 @@ export const startEnvironment = async (options: { if (services.includes("explorer")) { files.push( explorer({ - imageTag: "2.0.0-alpha.2", + imageTag: "2.0.0-alpha.3", port, }), ); @@ -471,6 +490,8 @@ export const deployApplication = async (options: { prt?: boolean; salt?: Hex; snapshotPath: string; + withdrawalConfig?: WithdrawalConfig; + claimStagingPeriod: number; }): Promise => { const { consensus, @@ -480,6 +501,8 @@ export const deployApplication = async (options: { prt, salt, snapshotPath, + withdrawalConfig, + claimStagingPeriod, } = options; // app deploy args @@ -490,12 +513,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/src/types/chain.ts b/apps/cli/src/types/chain.ts new file mode 100644 index 00000000..1ec2e09d --- /dev/null +++ b/apps/cli/src/types/chain.ts @@ -0,0 +1,5 @@ +export type ForkConfig = { + blockNumber?: bigint; + chainId: number; + url: string; +}; diff --git a/apps/cli/src/validations.ts b/apps/cli/src/validations.ts new file mode 100644 index 00000000..6495a28c --- /dev/null +++ b/apps/cli/src/validations.ts @@ -0,0 +1,283 @@ +import { + createPublicClient, + http, + zeroAddress, + zeroHash, + type Abi, + type Address, + type ReadContractParameters, +} from "viem"; +import { + daveAppFactoryAbi, + daveAppFactoryAddress, + erc1155BatchPortalConfig, + erc1155SinglePortalConfig, + erc20PortalConfig, + erc721PortalConfig, + etherPortalConfig, + inputBoxConfig, +} from "./contracts"; +import ForkChainValidationError from "./errors/ForkChainValidationError"; +import UnsupportedForkChainError from "./errors/UnsupportedForkChainError"; +import type { ForkConfig } from "./types/chain"; + +interface AssertForkConfigOptions { + includePRT?: boolean; +} + +interface ContractCheckersOptions extends AssertForkConfigOptions { + chainId: number; + blockNumber: bigint; +} + +interface ContractCheckerConfig { + name: string; + getAddress: () => Address; + abi: Abi; + getReadContractParameters: () => ReadContractParameters; +} + +const getContractCheckers = (options: ContractCheckersOptions) => { + const checkers: ContractCheckerConfig[] = [ + { + name: "InputBox", + getAddress() { + return inputBoxConfig.address; + }, + abi: inputBoxConfig.abi, + getReadContractParameters(): ReadContractParameters< + typeof inputBoxConfig.abi + > { + return { + address: this.getAddress(), + abi: inputBoxConfig.abi, + functionName: "getDeploymentBlockNumber", + args: [], + blockNumber: options.blockNumber, + }; + }, + }, + { + name: "EtherPortal", + getAddress() { + return etherPortalConfig.address; + }, + abi: etherPortalConfig.abi, + getReadContractParameters(): ReadContractParameters< + typeof etherPortalConfig.abi + > { + return { + address: this.getAddress(), + abi: etherPortalConfig.abi, + functionName: "getInputBox", + args: [], + blockNumber: options.blockNumber, + }; + }, + }, + { + name: "ERC20Portal", + getAddress() { + return erc20PortalConfig.address; + }, + abi: erc20PortalConfig.abi, + getReadContractParameters(): ReadContractParameters< + typeof erc20PortalConfig.abi + > { + return { + address: this.getAddress(), + abi: erc20PortalConfig.abi, + functionName: "getInputBox", + args: [], + blockNumber: options.blockNumber, + }; + }, + }, + { + name: "ERC721Portal", + getAddress() { + return erc721PortalConfig.address; + }, + abi: erc721PortalConfig.abi, + getReadContractParameters(): ReadContractParameters< + typeof erc721PortalConfig.abi + > { + return { + address: this.getAddress(), + abi: erc721PortalConfig.abi, + functionName: "getInputBox", + args: [], + blockNumber: options.blockNumber, + }; + }, + }, + { + name: "ERC1155SinglePortal", + getAddress() { + return erc1155SinglePortalConfig.address; + }, + abi: erc1155SinglePortalConfig.abi, + getReadContractParameters(): ReadContractParameters< + typeof erc1155SinglePortalConfig.abi + > { + return { + address: this.getAddress(), + abi: erc1155SinglePortalConfig.abi, + functionName: "getInputBox", + args: [], + blockNumber: options.blockNumber, + }; + }, + }, + { + name: "ERC1155BatchPortal", + getAddress() { + return erc1155BatchPortalConfig.address; + }, + abi: erc1155BatchPortalConfig.abi, + getReadContractParameters(): ReadContractParameters< + typeof erc1155BatchPortalConfig.abi + > { + return { + address: this.getAddress(), + abi: erc1155BatchPortalConfig.abi, + functionName: "getInputBox", + args: [], + blockNumber: options.blockNumber, + }; + }, + }, + ]; + + if (options.includePRT) { + checkers.push({ + name: "DaveAppFactory", + getAddress() { + const address = + daveAppFactoryAddress[ + options.chainId as keyof typeof daveAppFactoryAddress + ]; + + if (!address) { + throw new UnsupportedForkChainError( + options.chainId, + "DaveAppFactory address not found for this chain.", + ); + } + + return address; + }, + abi: daveAppFactoryAbi, + getReadContractParameters(): ReadContractParameters< + typeof daveAppFactoryAbi + > { + return { + address: this.getAddress(), + abi: daveAppFactoryAbi, + functionName: "calculateDaveAppAddress", + args: [ + zeroHash, + { + accountsDriveStartIndex: 0n, + guardian: zeroAddress, + log2LeavesPerAccount: 0, + log2MaxNumOfAccounts: 0, + withdrawalOutputBuilder: zeroAddress, + }, + zeroHash, + ], + blockNumber: options.blockNumber, + }; + }, + }); + } + + return checkers; +}; + +/** + * Asserts that the provided fork configuration is valid by checking if the required contracts are deployed and + * callable on the specified chain and block number. + * If the fork configuration is invalid, an UnsupportedForkChainError is thrown with details + * about the missing contracts or failed calls. + * @param forkConfig - The fork configuration to validate. + * @param options - Optional parameters to customize the validation behavior. + * @returns A promise that resolves if the fork configuration is valid, or rejects with an UnsupportedForkChainError if invalid. + */ +export const assertForkConfig = async ( + forkConfig: ForkConfig, + options?: AssertForkConfigOptions, +) => { + const { chainId, url, blockNumber } = forkConfig; + const client = createPublicClient({ + transport: http(url), + batch: { multicall: true }, + }); + + const forkBlockNumber = blockNumber ?? (await client.getBlockNumber()); + const checkers = getContractCheckers({ + chainId, + blockNumber: forkBlockNumber, + ...options, + }); + + const bytecodes = await Promise.allSettled( + checkers.map((checker) => { + return client.getCode({ + address: checker.getAddress(), + blockNumber: forkBlockNumber, + }); + }), + ); + + const errorMessages: string[] = []; + + bytecodes.forEach((result, index) => { + if (result.status === "rejected") { + const baseMessage = `Contract ${checkers[index].name} at address ${checkers[index].getAddress()} on chain ${chainId} at block ${forkBlockNumber} got a problem when fetching its bytecode\n`; + const origMessage = + result.reason.shortMessage ?? + result.reason.message ?? + "Unknown error"; + + throw new ForkChainValidationError( + chainId, + `${baseMessage}Original error: ${origMessage}`, + ); + } else { + if (result.value === "0x" || result.value === undefined) { + errorMessages.push( + `No deployment found at address ${checkers[index].getAddress()} for contract ${checkers[index].name} on chain ${chainId} at block ${forkBlockNumber}`, + ); + } + } + }); + + if (errorMessages.length > 0) { + throw new UnsupportedForkChainError(chainId, errorMessages.join("\n")); + } + + const results = await Promise.allSettled( + checkers.map((checker) => { + return client.readContract(checker.getReadContractParameters()); + }), + ); + + results.forEach((result, index) => { + if (result.status === "rejected") { + const origMessage = + result.reason.shortMessage ?? + result.reason.message ?? + "Unknown error"; + errorMessages.push( + `Contract ${checkers[index].name} at address ${checkers[index].getAddress()} on chain ${chainId} at block ${forkBlockNumber} got a problem when calling function ${checkers[index].getReadContractParameters().functionName}\nOriginal error: ${origMessage}`, + ); + } + }); + + if (errorMessages.length > 0) { + throw new UnsupportedForkChainError(chainId, errorMessages.join("\n")); + } + + return true; +}; diff --git a/apps/cli/tests/integration/config.ts b/apps/cli/tests/integration/config.ts index d5c17e6f..b8fb643e 100644 --- a/apps/cli/tests/integration/config.ts +++ b/apps/cli/tests/integration/config.ts @@ -1,4 +1,7 @@ import { execa } from "execa"; +import fs from "node:fs"; +import path from "node:path"; +import tmp from "tmp"; import { DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION } from "../../src/config.js"; export const TEST_SDK = `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`; @@ -30,6 +33,83 @@ export async function ensureDockerImage(image: string): Promise { } } +/** + * Creates a sandboxed Cartesi application inside a temporary directory, + * builds it, and returns the path to the resulting build/machine and the directory itself. + * + * @returns {Promise<{ appDir: string, machineDir: string, cleanup: () => void }>} + */ +export async function createTemporaryCartesiApplication(): Promise<{ + appDir: string; + machineDir: string; + cleanup: () => void; +}> { + // Create a secure temporary directory that auto-cleans on exit + const tempDir = tmp.dirSync({ unsafeCleanup: true }); + const cliPath = path.join(__dirname, "..", "..", "dist", "index.js"); + + if (!fs.existsSync(cliPath)) { + throw new Error( + `CLI binary not found at ${cliPath}. Please build the CLI before running tests.`, + ); + } + + console.log(`✓ Temporary sandbox directory created at: ${tempDir.name}`); + console.log(`✓ CLI binary located at: ${cliPath}`); + console.log(`✓ Using Docker image: ${TEST_SDK}`); + console.log(`! Creating a temporary Cartesi application in the sandbox...`); + + // Save original working directory + const originalCwd = process.cwd(); + + try { + // Switch process working directory into the sandbox + process.chdir(tempDir.name); + + await execa("node", [ + cliPath, + "create", + "--template", + "typescript", + "temp-app", + ]); + + const appDir = path.join(tempDir.name, "temp-app"); + + console.log(`✓ Temporary Cartesi application created at: ${appDir}`); + + // Change directory into the created application + process.chdir(appDir); + + console.log(`! Building the temporary Cartesi application...`); + + // Programmatically BUILD the application + await execa("node", [cliPath, "build"], { + stdio: ["ignore", "pipe", "pipe"], + }); + + console.log(`✓ Temporary Cartesi application built successfully.`); + + // Path to the compiled machine image + const machineDir = path.join(appDir, ".cartesi", "image"); + + return { + appDir, + machineDir, + cleanup: () => { + // Restore working directory and wipe temporary files + process.chdir(originalCwd); + tempDir.removeCallback(); + }, + }; + } catch (error) { + // Ensure cwd is restored even if build/creation fails + process.chdir(originalCwd); + tempDir.removeCallback(); + throw error; + } +} + /** * Global setup function for integration tests. * Call this before running integration tests to ensure Docker image is ready. diff --git a/apps/cli/tests/integration/exec/cartesi-machine-stored-hash.test.ts b/apps/cli/tests/integration/exec/cartesi-machine-stored-hash.test.ts new file mode 100644 index 00000000..48f78a40 --- /dev/null +++ b/apps/cli/tests/integration/exec/cartesi-machine-stored-hash.test.ts @@ -0,0 +1,88 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import tmp from "tmp"; +import { cartesiMachineStoredHash } from "../../../src/exec"; +import { + createTemporaryCartesiApplication, + setupIntegrationTests, + TEST_SDK, +} from "../config"; + +let machineDir: string; +let cleanupTempApplication: () => void; + +beforeAll( + async () => { + await setupIntegrationTests(); + const result = await createTemporaryCartesiApplication(); + machineDir = result.machineDir; + cleanupTempApplication = result.cleanup; + }, + { timeout: 60000 * 20 }, +); + +afterAll(() => { + if (cleanupTempApplication) { + cleanupTempApplication(); + } +}); + +describe("cartesi-machine-stored-hash", () => { + it("should return the correct computed hash", async () => { + const machineHash = await cartesiMachineStoredHash.computeHash(".", { + forceDocker: true, + image: TEST_SDK, + cwd: machineDir, + }); + + expect(machineHash).toBeDefined(); + expect(machineHash).toEqual( + "0xa4d4fd596e8220332f47c1005b7bd2d1b08ea007b6b2381a96512f9dc49458fa", + ); + }); + + it("should return undefined for a non-existent machine directory", async () => { + const machineDir = path.join("random", ".cartesi", "image"); + + const machineHash = await cartesiMachineStoredHash.computeHash( + machineDir, + { + forceDocker: true, + image: TEST_SDK, + cwd: import.meta.dirname, + }, + ); + + expect(machineHash).toBeUndefined(); + }); + + it("should return undefined when given an empty/corrupted machine directory", async () => { + // Create an empty temporary directory simulating missing machine structures + const tempDir = tmp.dirSync({ unsafeCleanup: true }); + + // write an empty config.json file to simulate a corrupted machine directory + writeFileSync(path.join(tempDir.name, "config.json"), "{}"); + const machineHash = await cartesiMachineStoredHash.computeHash(".", { + forceDocker: true, + image: TEST_SDK, + cwd: tempDir.name, + }); + + expect(machineHash).toBeUndefined(); + tempDir.removeCallback(); + }); + + it("should return undefined when a file path is passed instead of a directory", async () => { + // Create a temporary blank file + const tempFile = tmp.fileSync(); + const machineHash = await cartesiMachineStoredHash.computeHash(".", { + forceDocker: true, + image: TEST_SDK, + cwd: tempFile.name, + }); + + expect(machineHash).toBeUndefined(); + tempFile.removeCallback(); + }); +}); 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/tests/unit/validations.test.ts b/apps/cli/tests/unit/validations.test.ts new file mode 100644 index 00000000..3634fa20 --- /dev/null +++ b/apps/cli/tests/unit/validations.test.ts @@ -0,0 +1,294 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { celo, sepolia } from "viem/chains"; +import { + daveAppFactoryAddress, + erc1155BatchPortalConfig, + erc1155SinglePortalConfig, + erc20PortalConfig, + erc721PortalConfig, + etherPortalConfig, + inputBoxConfig, +} from "../../src/contracts"; +import ForkChainValidationError from "../../src/errors/ForkChainValidationError"; +import UnsupportedForkChainError from "../../src/errors/UnsupportedForkChainError"; +import type { ForkConfig } from "../../src/types/chain"; +import { assertForkConfig } from "../../src/validations"; + +const mockGetBlockNumber = mock().mockResolvedValue(12345n); +const mockGetCode = mock().mockResolvedValue("0x1234"); +const mockReadContract = mock().mockResolvedValue("some-result"); + +mock.module("viem", () => { + return { + createPublicClient: () => ({ + getBlockNumber: mockGetBlockNumber, + getCode: mockGetCode, + readContract: mockReadContract, + }), + http: () => "mock-http-transport", + zeroAddress: "0x0000000000000000000000000000000000000000", + zeroHash: + "0x0000000000000000000000000000000000000000000000000000000000000000", + }; +}); + +describe("validations", () => { + const baseConfig: ForkConfig = { + chainId: sepolia.id, + url: sepolia.rpcUrls.default.http[0], + }; + + beforeEach(() => { + mockGetBlockNumber.mockClear(); + mockGetCode.mockClear(); + mockReadContract.mockClear(); + }); + + describe("assertForkConfig", () => { + it("should pass if all contracts are deployed and readable", async () => { + mockGetBlockNumber.mockResolvedValueOnce(1n); + mockGetCode.mockResolvedValue("0x1234"); + mockReadContract.mockResolvedValue("some-result"); + + await expect(assertForkConfig(baseConfig)).resolves.toBe(true); + expect(mockGetBlockNumber).toHaveBeenCalledTimes(1); + expect(mockGetCode).toHaveBeenCalledTimes(6); + expect(mockReadContract).toHaveBeenCalledTimes(6); + + expect(mockGetCode).toHaveBeenCalledWith( + expect.objectContaining({ + address: inputBoxConfig.address, + }), + ); + + expect(mockGetCode).toHaveBeenCalledWith( + expect.objectContaining({ + address: etherPortalConfig.address, + }), + ); + + expect(mockGetCode).toHaveBeenCalledWith( + expect.objectContaining({ + address: erc20PortalConfig.address, + }), + ); + + expect(mockGetCode).toHaveBeenCalledWith( + expect.objectContaining({ + address: erc721PortalConfig.address, + }), + ); + + expect(mockGetCode).toHaveBeenCalledWith( + expect.objectContaining({ + address: erc1155SinglePortalConfig.address, + }), + ); + + expect(mockGetCode).toHaveBeenCalledWith( + expect.objectContaining({ + address: erc1155BatchPortalConfig.address, + }), + ); + + expect(mockGetCode).not.toHaveBeenCalledWith( + expect.objectContaining({ + address: daveAppFactoryAddress[sepolia.id], + }), + ); + + // readContract + + expect(mockReadContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: inputBoxConfig.address, + }), + ); + + expect(mockReadContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: etherPortalConfig.address, + }), + ); + + expect(mockReadContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: erc20PortalConfig.address, + }), + ); + + expect(mockReadContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: erc721PortalConfig.address, + }), + ); + + expect(mockReadContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: erc1155SinglePortalConfig.address, + }), + ); + + expect(mockReadContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: erc1155BatchPortalConfig.address, + }), + ); + + expect(mockReadContract).not.toHaveBeenCalledWith( + expect.objectContaining({ + address: daveAppFactoryAddress[sepolia.id], + }), + ); + }); + + it("should throw UnsupportedForkChainError if contract code is empty ('0x')", async () => { + mockGetCode.mockResolvedValueOnce("0x"); // First contract fails + mockGetCode.mockResolvedValue("0x1234"); // Rest pass + mockReadContract.mockResolvedValue("some-result"); + + let thrownError: Error | undefined; + try { + await assertForkConfig(baseConfig); + } catch (e) { + thrownError = e as Error; + } + + expect(thrownError).toBeInstanceOf(UnsupportedForkChainError); + expect(thrownError?.message).toContain( + `No deployment found at address ${inputBoxConfig.address} for contract InputBox on chain 11155111 at block 12345`, + ); + }); + + it("should throw UnsupportedForkChainError if contract code is undefined", async () => { + mockGetCode.mockResolvedValueOnce(undefined); + mockGetCode.mockResolvedValue("0x1234"); + mockReadContract.mockResolvedValue("some-result"); + + let thrownError: Error | undefined; + try { + await assertForkConfig(baseConfig); + } catch (e) { + thrownError = e as Error; + } + + expect(thrownError).toBeInstanceOf(UnsupportedForkChainError); + expect(thrownError?.message).toContain( + `No deployment found at address ${inputBoxConfig.address} for contract InputBox on chain 11155111 at block 12345`, + ); + }); + + it("should throw a generic ForkChainValidationError if contract code calls is rate-limited", async () => { + mockGetCode.mockResolvedValueOnce("0x1234"); + mockGetCode.mockRejectedValue({ + shortMessage: "Rate limit exceeded. Status Code 429", + }); + mockReadContract.mockResolvedValue("some-result"); + + let thrownError: Error | undefined; + try { + await assertForkConfig(baseConfig); + } catch (e) { + thrownError = e as Error; + } + + expect(thrownError).toBeInstanceOf(ForkChainValidationError); + expect(thrownError?.message).toContain( + "An error happened during validation of fork chain 11155111", + ); + expect(thrownError?.message).toContain( + `Original error: Rate limit exceeded. Status Code 429`, + ); + }); + + it("should throw UnsuppotedForkChainError if chain id is not supported (daveAppFactoryAddress is undefined)", async () => { + const unsupportedConfig: ForkConfig = { + chainId: celo.id, + url: "https://example.com", + }; + + mockGetCode.mockResolvedValue("0x1234"); + mockReadContract.mockResolvedValue("some-result"); + + let thrownError: Error | undefined; + try { + await assertForkConfig(unsupportedConfig, { includePRT: true }); + } catch (e) { + thrownError = e as Error; + } + + expect(thrownError).toBeInstanceOf(UnsupportedForkChainError); + expect(thrownError?.message).toContain( + `Unsupported fork chain ${unsupportedConfig.chainId}`, + ); + expect(thrownError?.message).toContain( + "DaveAppFactory address not found for this chain", + ); + }); + + it("should throw UnsupportedForkChainError if contract read is rejected", async () => { + mockGetCode.mockResolvedValue("0x1234"); + const rejectionReason = { shortMessage: "RPC execution error" }; + mockReadContract.mockRejectedValueOnce(rejectionReason); + mockReadContract.mockResolvedValue("some-result"); + + let thrownError: Error | undefined; + try { + await assertForkConfig(baseConfig); + } catch (e) { + thrownError = e as Error; + } + + expect(thrownError).toBeInstanceOf(UnsupportedForkChainError); + expect(thrownError?.message).toContain( + "Unsupported fork chain 11155111", + ); + expect(thrownError?.message).toContain( + "got a problem when calling function", + ); + expect(thrownError?.message).toContain( + "Original error: RPC execution error", + ); + }); + + it("should include PRT contracts when includePRT is true", async () => { + mockGetCode.mockResolvedValue("0x1234"); + mockReadContract.mockResolvedValue("some-result"); + + await expect( + assertForkConfig(baseConfig, { includePRT: true }), + ).resolves.toEqual(true); + + // 6 base contracts + 1 PRT contract = 7 + expect(mockGetCode.mock.calls.length).toBe(7); + expect(mockReadContract.mock.calls.length).toBe(7); + + expect(mockGetCode).toHaveBeenCalledWith( + expect.objectContaining({ + address: daveAppFactoryAddress[sepolia.id], + }), + ); + + expect(mockReadContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: daveAppFactoryAddress[sepolia.id], + }), + ); + }); + + it("should not refetch blockNumber if it is provided", async () => { + const config = { + ...baseConfig, + blockNumber: 999n, + }; + + mockGetCode.mockResolvedValue("0x1234"); + mockReadContract.mockResolvedValue("some-result"); + + await expect(assertForkConfig(config)).resolves.toEqual(true); + expect(mockGetBlockNumber).not.toHaveBeenCalled(); + // getCode should be called with blockNumber: 999n + expect(mockGetCode.mock.calls[0][0].blockNumber).toBe(999n); + }); + }); +}); 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, ); };