From 4dc316f33b9e0adc40a2fefb61a24a105c66844a Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 20 Oct 2026 18:41:36 +0100 Subject: [PATCH 01/17] feat(cli): require cartesi-machine 0.22 --- .changeset/seven-moments-prove.md | 5 +++++ apps/cli/src/exec/cartesi-machine.ts | 2 +- apps/cli/tests/unit/exec/cartesi-machine.test.ts | 8 ++++---- 3 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 .changeset/seven-moments-prove.md diff --git a/.changeset/seven-moments-prove.md b/.changeset/seven-moments-prove.md new file mode 100644 index 00000000..3afe7349 --- /dev/null +++ b/.changeset/seven-moments-prove.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": minor +--- + +Require cartesi-machine 0.22. Machines stored by 0.21 can't be loaded by 0.22: rebuild and redeploy every application. diff --git a/apps/cli/src/exec/cartesi-machine.ts b/apps/cli/src/exec/cartesi-machine.ts index 30571500..1fb60112 100644 --- a/apps/cli/src/exec/cartesi-machine.ts +++ b/apps/cli/src/exec/cartesi-machine.ts @@ -4,7 +4,7 @@ import { type ExecaOptionsDockerFallback, } from "./util.js"; -export const requiredVersion = new Range("^0.21.0"); +export const requiredVersion = new Range("^0.22.0"); export const boot = ( args: readonly string[], diff --git a/apps/cli/tests/unit/exec/cartesi-machine.test.ts b/apps/cli/tests/unit/exec/cartesi-machine.test.ts index eb0c10cc..2ef48f52 100644 --- a/apps/cli/tests/unit/exec/cartesi-machine.test.ts +++ b/apps/cli/tests/unit/exec/cartesi-machine.test.ts @@ -9,11 +9,11 @@ import { const semver = (version: string) => parse(version) as SemVer; describe("requiredVersion", () => { - it.each(["0.21.0", "0.21.3"])("should accept %s", (version) => { + it.each(["0.22.0", "0.22.3"])("should accept %s", (version) => { expect(satisfies(version, requiredVersion)).toBeTruthy(); }); - it.each(["0.20.0", "0.20.9", "0.22.0", "1.0.0"])( + it.each(["0.21.0", "0.21.9", "0.23.0", "1.0.0"])( "should reject %s", (version) => { expect(satisfies(version, requiredVersion)).toBeFalsy(); @@ -22,11 +22,11 @@ describe("requiredVersion", () => { }); describe("assertSupported", () => { - it.each(["0.21.0", "0.21.3"])("should accept %s", (version) => { + it.each(["0.22.0", "0.22.3"])("should accept %s", (version) => { expect(() => assertSupported(semver(version))).not.toThrow(); }); - it.each(["0.20.0", "0.22.0"])("should reject %s", (version) => { + it.each(["0.21.0", "0.23.0"])("should reject %s", (version) => { expect(() => assertSupported(semver(version))).toThrowError( new UnsupportedVersionError(semver(version)), ); From e3ab2303846aef32ac60103962ab33d3957f5baa Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 20 Oct 2026 18:41:36 +0100 Subject: [PATCH 02/17] test(cli): withdraw from nvram accounts drives --- .../commands/fixtures/accounts-withdrawal-layouts.ts | 6 ------ .../tests/integration/commands/withdrawal-layouts.test.ts | 4 +--- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/cli/tests/integration/commands/fixtures/accounts-withdrawal-layouts.ts b/apps/cli/tests/integration/commands/fixtures/accounts-withdrawal-layouts.ts index 4b39f691..e05b8581 100644 --- a/apps/cli/tests/integration/commands/fixtures/accounts-withdrawal-layouts.ts +++ b/apps/cli/tests/integration/commands/fixtures/accounts-withdrawal-layouts.ts @@ -21,9 +21,6 @@ export type Layout = { at: number; // offset of data, the rest is zeros data: Buffer; }; - // TODO: the machine-tool of rollups-node v2.0.0-alpha.13 only looks for the - // accounts drive among the flash drives, unskip once it finds nvrams too - skip?: boolean; }; const scratchDrive = ` @@ -76,7 +73,6 @@ accounts_drive = true `, label: "accounts", device: "/dev/uio0", - skip: true, }, { name: "the beginning of a larger nvram", @@ -96,7 +92,6 @@ accounts_drive_size = "4Mi" label: "state", device: "/dev/uio1", accountsSize: 4 * MiB, - skip: true, }, { // like a DEX, which memory-maps its state after the accounts, from an image @@ -118,6 +113,5 @@ accounts_drive_size = "4Mi" at: 4 * MiB, data: Buffer.alloc(8 * MiB, 0xab), }, - skip: true, }, ]; diff --git a/apps/cli/tests/integration/commands/withdrawal-layouts.test.ts b/apps/cli/tests/integration/commands/withdrawal-layouts.test.ts index 350e7529..02ed278c 100644 --- a/apps/cli/tests/integration/commands/withdrawal-layouts.test.ts +++ b/apps/cli/tests/integration/commands/withdrawal-layouts.test.ts @@ -65,9 +65,7 @@ ${layout.accountsSize ? `ACCOUNTS_SIZE = "${layout.accountsSize}"` : ""} }; layouts.forEach((layout, index) => { - const describeLayout = layout.skip ? describe.skip : describe; - - describeLayout(`emergency withdrawal from ${layout.name}`, () => { + describe(`emergency withdrawal from ${layout.name}`, () => { const projectName = `layouts-${index}-${process.pid}`; let appDir: string; let cleanup: () => void; From 9f8f4b810b78b8f672bc216ba6900ef355ee2e69 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Wed, 28 Oct 2026 20:42:39 +0000 Subject: [PATCH 03/17] test(cli): withdraw next to an account with no balance --- .../fixtures/accounts-withdrawal/dapp.sh | 2 +- .../integration/commands/recovery.test.ts | 29 +++++++++++++------ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/cli/tests/integration/commands/fixtures/accounts-withdrawal/dapp.sh b/apps/cli/tests/integration/commands/fixtures/accounts-withdrawal/dapp.sh index fd7c9b5b..aaa2e5f6 100755 --- a/apps/cli/tests/integration/commands/fixtures/accounts-withdrawal/dapp.sh +++ b/apps/cli/tests/integration/commands/fixtures/accounts-withdrawal/dapp.sh @@ -106,8 +106,8 @@ handle_erc20_deposit() { [[ "$token" == "$trusted_token" ]] || return 1 [[ "${amount_hex:0:49}" =~ ^0{48}[0-7]$ ]] || return 1 + # a zero deposit opens an account with no balance, as one emptied by a debit would be amount="$(uint64_be_to_dec "${amount_hex:48:16}")" - (( amount > 0 )) || return 1 credit_account "$sender" "$amount" } diff --git a/apps/cli/tests/integration/commands/recovery.test.ts b/apps/cli/tests/integration/commands/recovery.test.ts index 2ffb6158..9c1b634f 100644 --- a/apps/cli/tests/integration/commands/recovery.test.ts +++ b/apps/cli/tests/integration/commands/recovery.test.ts @@ -42,8 +42,10 @@ import { * The runtime must ship a rollups node with the refund command, as the * default SDK does. * - * Inputs: alice (0), bob (1) and erin (2) are finalized in epoch 0; carol (3) - * and dave (4) stay in the open epoch 1 and are refunded. + * Inputs: alice (0), bob (1), erin (2) and frank (3) are finalized in epoch 0; + * carol (4) and dave (5) stay in the open epoch 1 and are refunded. frank + * deposits nothing, leaving an account with no balance in the drive, which + * must not stop the other accounts from being proven. */ const EPOCH_LENGTH = 720; @@ -56,13 +58,21 @@ const bob = devnetAccount(2); const carol = devnetAccount(3); const dave = devnetAccount(4); const erin = devnetAccount(5); +const frank = devnetAccount(6); // the guardian comes from a mnemonic the node doesn't sign with const guardianMnemonic = "legal winner thank year wave sausage worth useful legal winner thank yellow"; const guardian = mnemonicToAccount(guardianMnemonic).address; -const amounts = { alice: 100n, bob: 50n, carol: 25n, dave: 15n, erin: 10n }; +const amounts = { + alice: 100n, + bob: 50n, + carol: 25n, + dave: 15n, + erin: 10n, + frank: 0n, +}; const count = (text: string, part: string) => text.split(part).length - 1; @@ -177,6 +187,7 @@ guardian = "${guardian}" await deposit(alice, amounts.alice); await deposit(bob, amounts.bob); await deposit(erin, amounts.erin); + await deposit(frank, amounts.frank); // close the first epoch and wait for its claim to be accepted await anvil.mine({ blocks: EPOCH_LENGTH }); @@ -193,7 +204,7 @@ guardian = "${guardian}" await deposit(carol, amounts.carol); await deposit(dave, amounts.dave); await waitFor(() => - getNodeInput({ application, inputIndex: 4n, projectName }), + getNodeInput({ application, inputIndex: 5n, projectName }), ); }, TIMEOUT, @@ -203,7 +214,7 @@ guardian = "${guardian}" it( "should pass the node's NotForeclosed error through on refund", async () => { - const refund = await cli(["refund", "3", "--yes"]); + const refund = await cli(["refund", "4", "--yes"]); expect(refund.exitCode).not.toBe(0); expect(refund.all).toContain("NotForeclosed"); }, @@ -273,10 +284,10 @@ guardian = "${guardian}" "should refund a deposit that was not finalized", async () => { expect(await usdcBalance(carol)).toBe(0n); - const refund = await cli(["refund", "3", "--yes", "--json"]); + const refund = await cli(["refund", "4", "--yes", "--json"]); expect(refund.exitCode, refund.all).toBe(0); expect(JSON.parse(refund.stdout)).toMatchObject({ - input_index: "3", + input_index: "4", }); expect(await usdcBalance(carol)).toBe(amounts.carol); }, @@ -288,7 +299,7 @@ guardian = "${guardian}" async () => { const rawData = await getNodeInput({ application, - inputIndex: 4n, + inputIndex: 5n, projectName, }); const inputFile = path.join(appDir, "dave-input.hex"); @@ -296,7 +307,7 @@ guardian = "${guardian}" expect(await usdcBalance(dave)).toBe(0n); const refund = await cli([ "refund", - "4", + "5", "--input-file", inputFile, "--yes", From 4cd6481294eb06106242935c9e61f2f5231ce8d0 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Wed, 4 Nov 2026 09:33:53 +0000 Subject: [PATCH 04/17] chore(cli): bump @cartesi/wagmi-plugin to 1.0.0-alpha.9 --- .changeset/strict-grapes-do.md | 5 +++++ apps/cli/package.json | 2 +- bun.lock | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 .changeset/strict-grapes-do.md diff --git a/.changeset/strict-grapes-do.md b/.changeset/strict-grapes-do.md new file mode 100644 index 00000000..720d1276 --- /dev/null +++ b/.changeset/strict-grapes-do.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Use the Dave 3.0.0-alpha.6 PRT contracts. Every PRT address moves, including the devnet DaveAppFactory. The rollups contract addresses are unchanged. diff --git a/apps/cli/package.json b/apps/cli/package.json index 9933cca3..a5c3acb2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -14,7 +14,7 @@ "/dist" ], "devDependencies": { - "@cartesi/wagmi-plugin": "1.0.0-alpha.7", + "@cartesi/wagmi-plugin": "1.0.0-alpha.9", "@commander-js/extra-typings": "^15.0.0", "@inquirer/confirm": "^6.3.3", "@inquirer/core": "^12.0.4", diff --git a/bun.lock b/bun.lock index 98f12444..bb3025ba 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "cartesi": "./dist/index.js", }, "devDependencies": { - "@cartesi/wagmi-plugin": "1.0.0-alpha.7", + "@cartesi/wagmi-plugin": "1.0.0-alpha.9", "@commander-js/extra-typings": "^15.0.0", "@inquirer/confirm": "^6.3.3", "@inquirer/core": "^12.0.4", @@ -113,7 +113,7 @@ "@cartesi/sdk": ["@cartesi/sdk@workspace:packages/sdk"], - "@cartesi/wagmi-plugin": ["@cartesi/wagmi-plugin@1.0.0-alpha.7", "", { "dependencies": { "abitype": "^1.3.0", "modern-tar": "^0.8.4" }, "peerDependencies": { "@wagmi/cli": "^2.10.0", "viem": "^2.0.0" } }, "sha512-oVgBBMxuEd/xa5OSxToGjRf7nOA9OyjXLmCyH8V19DjZ+k9GNFFfZSDsk7bbDHtia1Vnq4nl6aNU6dU0K7po3w=="], + "@cartesi/wagmi-plugin": ["@cartesi/wagmi-plugin@1.0.0-alpha.9", "", { "dependencies": { "abitype": "^1.3.0", "modern-tar": "^0.8.6" }, "peerDependencies": { "@wagmi/cli": "^2.10.0", "viem": "^2.0.0" } }, "sha512-NDk3CQhgY1uowuFz9QVpj0KycSN2nxlmHRM7SxwmtOwRDpZkTlhO1IYXs1YJk92+s3grS9igP8CbpXjCkB76mw=="], "@changesets/apply-release-plan": ["@changesets/apply-release-plan@8.1.1", "", { "dependencies": { "@changesets/config": "^4.0.1", "@changesets/format": "^0.1.2", "@changesets/git": "^4.0.1", "@changesets/should-skip-package": "^1.0.0", "@changesets/types": "^7.0.0", "import-meta-resolve": "^4.2.0", "jsonc-parser": "^3.3.1", "semver": "^7.8.1" } }, "sha512-nk2Hbq3M9NeHMdaIRBDvawKrQJzXB9sGyBUE5yWnIKtvdcNCPj75zBekJYr0gTT2ecJH3eFeTrc8eisPImcv0Q=="], From bbbef9375d9d31ba6e2b8fc2588169d7831da2f4 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 08:28:40 +0000 Subject: [PATCH 05/17] fix(cli): allow abandonment when undeploying --- apps/cli/src/exec/rollups.ts | 36 ++++-- .../integration/commands/undeploy.test.ts | 104 ++++++++++++++++++ apps/cli/tests/unit/exec/rollups.test.ts | 17 +++ 3 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 apps/cli/tests/integration/commands/undeploy.test.ts diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index 61219056..00f8ddcd 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -764,6 +764,27 @@ export const deployApplication = async ( throw new Error("Failed to deploy application"); }; +/** + * Assemble the `cartesi-rollups-cli app remove` arguments. + * Kept separate from the call so it can be tested without a node. + */ +export const buildRemoveApplicationArgs = (options: { + application: string | Address; + force: boolean; +}): string[] => { + const { application, force } = options; + + const removeArgs = [application]; + + if (force) { + // the node refuses to remove without acknowledging the work it abandons, + // and --yes only skips its confirmation prompt + removeArgs.push("--allow-abandonment", "--yes"); + } + + return removeArgs; +}; + /** * Remove application from rollups node * @param options @@ -774,7 +795,7 @@ export const removeApplication = async (options: { force: boolean; projectName: string; }) => { - const { application, force, projectName } = options; + const { application, projectName } = options; // disable application first so we can remove it await execNodeCommand({ @@ -788,14 +809,13 @@ export const removeApplication = async (options: { projectName, }); - const removeArgs = [application]; - - if (force) { - removeArgs.push("--yes"); - } - return execNodeCommand({ - command: ["cartesi-rollups-cli", "app", "remove", ...removeArgs], + command: [ + "cartesi-rollups-cli", + "app", + "remove", + ...buildRemoveApplicationArgs(options), + ], projectName, }); }; diff --git a/apps/cli/tests/integration/commands/undeploy.test.ts b/apps/cli/tests/integration/commands/undeploy.test.ts new file mode 100644 index 00000000..fa42e241 --- /dev/null +++ b/apps/cli/tests/integration/commands/undeploy.test.ts @@ -0,0 +1,104 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import type { ResultPromise } from "execa"; +import fs from "fs-extra"; +import os from "node:os"; +import path from "node:path"; +import { erc20PortalAddress, testUsdcAddress } from "../../../src/contracts"; +import { getDeployments, removeApplication } from "../../../src/exec/rollups"; +import { TEST_SDK } from "../config"; +import { + buildApplication, + startApplication, + stopApplication, + TIMEOUT, +} from "../devnet"; + +/** + * Covers the undeploy `cartesi run` does before redeploying a rebuilt + * application. The shell that rebuilds needs a terminal, so the removal is + * called the way the shell calls it, against an application of its own. + */ + +const EPOCH_LENGTH = 720; + +const fixture = path.join(__dirname, "fixtures", "accounts-withdrawal"); +const projectName = `undeploy-${process.pid}`; + +describe("undeploy", () => { + let appDir: string; + let cleanup: () => void; + let run: ResultPromise | undefined; + + beforeAll(async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "undeploy-")); + cleanup = () => fs.removeSync(dir); + appDir = path.join(dir, "accounts-withdrawal"); + await fs.copy(fixture, appDir); + await fs.writeFile( + path.join(appDir, "cartesi.toml"), + `sdk = "${TEST_SDK}" + +[machine.env] +TRUSTED_ERC20_PORTAL = "${erc20PortalAddress}" +TRUSTED_ERC20_TOKEN = "${testUsdcAddress}" +ACCOUNTS_LABEL = "accounts" + +[drives.accounts] +builder = "empty" +format = "raw" +size = "4MB" +mount = false +user = "dapp" +accounts_drive = true +`, + ); + + console.log("! Building the accounts-withdrawal application..."); + const build = await buildApplication(appDir); + expect(build.exitCode, build.all).toBe(0); + console.log("✓ Built the accounts-withdrawal application"); + + ({ run } = await startApplication({ + appDir, + epochLength: EPOCH_LENGTH, + projectName, + })); + }, TIMEOUT); + + afterAll(() => stopApplication({ cleanup, projectName, run }), TIMEOUT); + + // the node refuses to remove an application unless the removal + // acknowledges the work it abandons + it( + "should remove the deployed application", + async () => { + await removeApplication({ + application: projectName, + force: true, + projectName, + }); + + const deployments = await getDeployments({ projectName }); + expect(deployments.map(({ name }) => name)).not.toContain( + projectName, + ); + }, + TIMEOUT, + ); + + // a failed removal must reach the caller, or a rebuild would deploy over + // an application that is still registered + it( + "should fail to remove an application that was never deployed", + async () => { + await expect( + removeApplication({ + application: "never-deployed", + force: true, + projectName, + }), + ).rejects.toThrow(); + }, + TIMEOUT, + ); +}); diff --git a/apps/cli/tests/unit/exec/rollups.test.ts b/apps/cli/tests/unit/exec/rollups.test.ts index 23268c4d..c92f0563 100644 --- a/apps/cli/tests/unit/exec/rollups.test.ts +++ b/apps/cli/tests/unit/exec/rollups.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { buildDeployApplicationArgs, + buildRemoveApplicationArgs, parseApplications, parseLastAcceptedEpoch, parseNodeInput, @@ -268,3 +269,19 @@ describe("buildDeployApplicationArgs", () => { ); }); }); + +describe("buildRemoveApplicationArgs", () => { + // the node refuses `app remove` without --allow-abandonment, so a forced + // removal, like the shell's undeploy before a redeploy, must pass it + it("should acknowledge the abandoned work when forced", () => { + expect( + buildRemoveApplicationArgs({ application: "echo", force: true }), + ).toEqual(["echo", "--allow-abandonment", "--yes"]); + }); + + it("should leave the confirmation to the node when not forced", () => { + expect( + buildRemoveApplicationArgs({ application: "echo", force: false }), + ).toEqual(["echo"]); + }); +}); From d1fa0cc35cc6e35eb651cc221665730f6b7e0942 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 08:50:54 +0000 Subject: [PATCH 06/17] fix(cli): give the node the application factory for redeploys --- .changeset/hot-rings-decide.md | 5 +++ apps/cli/src/compose/node.ts | 5 +++ .../integration/commands/undeploy.test.ts | 45 +++++++++++++++++-- apps/cli/tests/unit/compose/node.test.ts | 7 ++- 4 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 .changeset/hot-rings-decide.md diff --git a/.changeset/hot-rings-decide.md b/.changeset/hot-rings-decide.md new file mode 100644 index 00000000..3f15cf29 --- /dev/null +++ b/.changeset/hot-rings-decide.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +`cartesi run`'s Build and redeploy no longer fails with "CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: variable not defined". The CLI now passes the devnet's ApplicationFactory address to the node, which `CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS` can still override. diff --git a/apps/cli/src/compose/node.ts b/apps/cli/src/compose/node.ts index 4c6e45c3..9d843b6e 100644 --- a/apps/cli/src/compose/node.ts +++ b/apps/cli/src/compose/node.ts @@ -1,6 +1,7 @@ import { anvil } from "viem/chains"; import type { CartesiEnvironmentVariables } from "../base.js"; import { + applicationFactoryAddress, daveAppFactoryAddress, inputBoxAddress, selfHostedApplicationFactoryAddress, @@ -130,6 +131,10 @@ const service = (options: ServiceOptions): Service => { CARTESI_BLOCKCHAIN_DEFAULT_BLOCK: defaultBlock, CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: "http://anvil:8545", CARTESI_BLOCKCHAIN_ID: anvil.id.toString(), + // a deploy on an existing consensus, as the shell's redeploy does, + // creates only the application, through this factory + CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: + applicationFactoryAddress, ...(chainDaveAppFactoryAddress && { CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: chainDaveAppFactoryAddress, diff --git a/apps/cli/tests/integration/commands/undeploy.test.ts b/apps/cli/tests/integration/commands/undeploy.test.ts index fa42e241..6c4a08f3 100644 --- a/apps/cli/tests/integration/commands/undeploy.test.ts +++ b/apps/cli/tests/integration/commands/undeploy.test.ts @@ -4,7 +4,12 @@ import fs from "fs-extra"; import os from "node:os"; import path from "node:path"; import { erc20PortalAddress, testUsdcAddress } from "../../../src/contracts"; -import { getDeployments, removeApplication } from "../../../src/exec/rollups"; +import { type Address, numberToHex } from "viem"; +import { + deployApplication, + getDeployments, + removeApplication, +} from "../../../src/exec/rollups"; import { TEST_SDK } from "../config"; import { buildApplication, @@ -14,9 +19,9 @@ import { } from "../devnet"; /** - * Covers the undeploy `cartesi run` does before redeploying a rebuilt - * application. The shell that rebuilds needs a terminal, so the removal is - * called the way the shell calls it, against an application of its own. + * Covers the undeploy and the redeploy `cartesi run` does for a rebuilt + * application. The shell that rebuilds needs a terminal, so both are called + * the way the shell calls them, against an application of its own. */ const EPOCH_LENGTH = 720; @@ -28,6 +33,7 @@ describe("undeploy", () => { let appDir: string; let cleanup: () => void; let run: ResultPromise | undefined; + let consensus: Address | undefined; beforeAll(async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "undeploy-")); @@ -63,6 +69,12 @@ accounts_drive = true epochLength: EPOCH_LENGTH, projectName, })); + + // read before any case removes the application + const deployments = await getDeployments({ projectName }); + consensus = deployments.find( + ({ name }) => name === projectName, + )?.consensus; }, TIMEOUT); afterAll(() => stopApplication({ cleanup, projectName, run }), TIMEOUT); @@ -86,6 +98,31 @@ accounts_drive = true TIMEOUT, ); + // the shell redeploys a rebuilt machine on the consensus of the first + // deploy, which the node does through the application factory + it( + "should deploy an application on an existing consensus", + async () => { + expect(consensus).toBeDefined(); + const name = `${projectName}-redeploy`; + + const deployment = await deployApplication({ + claimStagingPeriod: 0, + consensus, + epochLength: EPOCH_LENGTH, + name, + projectName, + salt: numberToHex(1, { size: 32 }), + snapshotPath: "/var/lib/cartesi-rollups-node/snapshots/image", + }); + + expect(deployment.consensus).toBe(consensus as Address); + const deployments = await getDeployments({ projectName }); + expect(deployments.map(({ name }) => name)).toContain(name); + }, + TIMEOUT, + ); + // a failed removal must reach the caller, or a rebuild would deploy over // an application that is still registered it( diff --git a/apps/cli/tests/unit/compose/node.test.ts b/apps/cli/tests/unit/compose/node.test.ts index b80a8354..05d2d72a 100644 --- a/apps/cli/tests/unit/compose/node.test.ts +++ b/apps/cli/tests/unit/compose/node.test.ts @@ -5,6 +5,7 @@ import buildNodeCompose, { type ServiceOptions, } from "../../../src/compose/node.js"; import { + applicationFactoryAddress, inputBoxAddress, selfHostedApplicationFactoryAddress, } from "../../../src/contracts.js"; @@ -215,7 +216,7 @@ describe("Compose node service", () => { const env = compose.services?.rollups_node?.environment ?? {}; - expect(Object.keys(env)).toHaveLength(11); + expect(Object.keys(env)).toHaveLength(12); expect(env).toHaveProperty( "CARTESI_AUTH_MNEMONIC", @@ -235,6 +236,10 @@ describe("Compose node service", () => { "http://anvil:8545", ); expect(env).toHaveProperty("CARTESI_BLOCKCHAIN_ID", "31337"); + expect(env).toHaveProperty( + "CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS", + applicationFactoryAddress, + ); expect(env).toHaveProperty( "CARTESI_CONTRACTS_INPUT_BOX_ADDRESS", inputBoxAddress, From 14fa861d1a5d0e81316ac3f54b5703d65d0383e8 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 09:00:17 +0000 Subject: [PATCH 07/17] fix(cli): stop passing the old consensus to prt redeploys --- apps/cli/src/exec/rollups.ts | 4 +++- apps/cli/tests/unit/exec/rollups.test.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index 00f8ddcd..62e5f153 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -711,7 +711,9 @@ export const buildDeployApplicationArgs = ( const deployArgs = [name, snapshotPath]; - if (consensus) { + // a prt deploy always creates its own DaveConsensus, which is bound to + // the machine, so an existing consensus is only reused without --prt + if (consensus && !prt) { deployArgs.push("--consensus", consensus); } else { deployArgs.push("--epoch-length", epochLength.toString()); diff --git a/apps/cli/tests/unit/exec/rollups.test.ts b/apps/cli/tests/unit/exec/rollups.test.ts index c92f0563..71e810eb 100644 --- a/apps/cli/tests/unit/exec/rollups.test.ts +++ b/apps/cli/tests/unit/exec/rollups.test.ts @@ -244,6 +244,20 @@ describe("buildDeployApplicationArgs", () => { expect(args).not.toContain("--epoch-length"); }); + // the shell's redeploy passes the last consensus, which a prt deploy + // can't reuse: the node creates a new DaveConsensus for the new machine + it("should not pass the consensus with prt", () => { + const args = buildDeployApplicationArgs({ + ...base, + consensus: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + prt: true, + }); + + expect(args).toContain("--prt"); + expect(args).not.toContain("--consensus"); + expect(args).toContain("--epoch-length"); + }); + it("should start with the name and snapshot path and end with --json", () => { const args = buildDeployApplicationArgs(base); From c3eb3eb3420eb9df6ebb18bfff9236123b8451cd Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 09:09:35 +0000 Subject: [PATCH 08/17] fix(cli): drop pending from --default-block --- .changeset/few-carrots-count.md | 5 ++ apps/cli/src/commands/run.ts | 2 +- apps/cli/src/compose/node.ts | 2 +- apps/cli/src/exec/rollups.ts | 2 +- .../tests/integration/commands/run.test.ts | 60 +++++++++++++++++++ apps/cli/tests/unit/commands/run.test.ts | 14 +++++ 6 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 .changeset/few-carrots-count.md create mode 100644 apps/cli/tests/integration/commands/run.test.ts create mode 100644 apps/cli/tests/unit/commands/run.test.ts diff --git a/.changeset/few-carrots-count.md b/.changeset/few-carrots-count.md new file mode 100644 index 00000000..29697167 --- /dev/null +++ b/.changeset/few-carrots-count.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +`cartesi run --default-block` no longer accepts `pending`, which the rollups-node no longer supports. Use `latest`, `safe` or `finalized`. diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 7afa2e86..09d00030 100755 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -334,7 +334,7 @@ export const createRunCommand = () => { "--default-block ", "default block to be used when fetching new blocks.", ) - .choices(["latest", "safe", "pending", "finalized"]) + .choices(["latest", "safe", "finalized"]) .default("latest"), ) .option("--dry-run", "show the docker compose configuration", false) diff --git a/apps/cli/src/compose/node.ts b/apps/cli/src/compose/node.ts index 9d843b6e..0a671686 100644 --- a/apps/cli/src/compose/node.ts +++ b/apps/cli/src/compose/node.ts @@ -20,7 +20,7 @@ export type ServiceOptions = { databaseHost?: string; databasePort?: number; databasePassword: string; - defaultBlock?: "latest" | "safe" | "pending" | "finalized"; + defaultBlock?: "latest" | "safe" | "finalized"; forkChainId?: number; logLevel?: "info" | "debug" | "warn" | "error" | "fatal"; memory?: number; diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index 62e5f153..5066a91c 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -455,7 +455,7 @@ const serviceMonitorTask = (options: { export const startEnvironment = async (options: { blockTime: number; cpus?: number; - defaultBlock: "latest" | "safe" | "pending" | "finalized"; + defaultBlock: "latest" | "safe" | "finalized"; detach: boolean; dryRun: boolean; forkConfig?: ForkConfig; diff --git a/apps/cli/tests/integration/commands/run.test.ts b/apps/cli/tests/integration/commands/run.test.ts new file mode 100644 index 00000000..46055685 --- /dev/null +++ b/apps/cli/tests/integration/commands/run.test.ts @@ -0,0 +1,60 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { execa } from "execa"; +import fs from "fs-extra"; +import os from "node:os"; +import path from "node:path"; +import { TEST_RUNTIME_VERSION } from "../config"; +import { cliPath } from "../devnet"; + +/** + * Covers what `cartesi run` checks before it starts anything. A dry run only + * renders the compose configuration, so no environment is started and no + * application needs to be built. + */ +describe("run", () => { + let dir: string; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "run-")); + }); + + afterAll(() => fs.removeSync(dir)); + + const dryRun = (args: string[]) => + execa( + "node", + [ + cliPath, + "run", + "--dry-run", + "--runtime-version", + TEST_RUNTIME_VERSION, + ...args, + ], + { all: true, cwd: dir, reject: false }, + ); + + describe("--default-block", () => { + it("should pass a supported default block to the node", async () => { + const { all, exitCode } = await dryRun(["--default-block", "safe"]); + + expect(exitCode, all).toBe(0); + expect(all).toContain("CARTESI_BLOCKCHAIN_DEFAULT_BLOCK: safe"); + }); + + // the node refuses to start with 'pending', so the CLI rejects it + // before rendering anything + it("should reject pending before rendering the configuration", async () => { + const { all, exitCode } = await dryRun([ + "--default-block", + "pending", + ]); + + expect(exitCode).not.toBe(0); + expect(all).toContain( + "Allowed choices are latest, safe, finalized", + ); + expect(all).not.toContain("CARTESI_BLOCKCHAIN_DEFAULT_BLOCK"); + }); + }); +}); diff --git a/apps/cli/tests/unit/commands/run.test.ts b/apps/cli/tests/unit/commands/run.test.ts new file mode 100644 index 00000000..7ec6c9ee --- /dev/null +++ b/apps/cli/tests/unit/commands/run.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "bun:test"; +import { createRunCommand } from "../../../src/commands/run"; + +describe("run command", () => { + // the node refuses to start with 'pending', which does not identify a + // mined block, so the command must not offer it + it("should offer only the default blocks the node supports", () => { + const option = createRunCommand().options.find( + ({ long }) => long === "--default-block", + ); + + expect(option?.argChoices).toEqual(["latest", "safe", "finalized"]); + }); +}); From e312a2bd3907cbb235a1d14d5e646f0db1968008 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 09:38:11 +0000 Subject: [PATCH 09/17] fix(cli): keep $ in values forwarded to compose --- .changeset/swift-berries-tie.md | 5 ++++ apps/cli/src/compose/anvil.ts | 5 ++-- apps/cli/src/compose/common.ts | 8 ++++++ apps/cli/src/compose/node.ts | 10 +++++-- .../tests/integration/commands/run.test.ts | 19 +++++++++++-- apps/cli/tests/unit/compose/anvil.test.ts | 28 +++++++++++++++++++ apps/cli/tests/unit/compose/node.test.ts | 20 +++++++++++++ 7 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 .changeset/swift-berries-tie.md create mode 100644 apps/cli/tests/unit/compose/anvil.test.ts diff --git a/.changeset/swift-berries-tie.md b/.changeset/swift-berries-tie.md new file mode 100644 index 00000000..69793080 --- /dev/null +++ b/.changeset/swift-berries-tie.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Keep `$` in the `CARTESI_*` variables `cartesi run` forwards to the node and in its `--fork-url`. Docker Compose read `$name` in them as a variable, so a value such as a password or an API key in a URL arrived changed. diff --git a/apps/cli/src/compose/anvil.ts b/apps/cli/src/compose/anvil.ts index c1607fea..69688480 100644 --- a/apps/cli/src/compose/anvil.ts +++ b/apps/cli/src/compose/anvil.ts @@ -1,6 +1,6 @@ import type { ForkConfig } from "../types/chain.js"; import type { ComposeFile, Config, Service } from "../types/compose.js"; -import { DEFAULT_HEALTHCHECK } from "./common.js"; +import { DEFAULT_HEALTHCHECK, escapeComposeValue } from "./common.js"; type ServiceOptions = { imageTag?: string; @@ -23,7 +23,8 @@ const service = (options?: ServiceOptions): Service => { "--block-time", blockTime.toString(), "--fork-url", - forkConfig.url, + // the URL may hold an API key with a `$`, which compose must not interpolate + escapeComposeValue(forkConfig.url), ...(forkConfig.blockNumber !== undefined ? ["--fork-block-number", forkConfig.blockNumber.toString()] : []), diff --git a/apps/cli/src/compose/common.ts b/apps/cli/src/compose/common.ts index fe8780c2..0aef4f4b 100644 --- a/apps/cli/src/compose/common.ts +++ b/apps/cli/src/compose/common.ts @@ -7,3 +7,11 @@ export const DEFAULT_HEALTHCHECK: Healthcheck = { timeout: "1s", retries: 5, }; + +/** + * Escapes a value for a compose file, which would otherwise read `$name` and + * `${name}` in it as variables and replace them, `$` and all. + * The container still gets the original value. + */ +export const escapeComposeValue = (value: string): string => + value.replaceAll("$", () => "$$"); diff --git a/apps/cli/src/compose/node.ts b/apps/cli/src/compose/node.ts index 0a671686..5d4c4bde 100644 --- a/apps/cli/src/compose/node.ts +++ b/apps/cli/src/compose/node.ts @@ -7,7 +7,7 @@ import { selfHostedApplicationFactoryAddress, } from "../contracts.js"; import type { ComposeFile, Config, Service } from "../types/compose.js"; -import { DEFAULT_HEALTHCHECK } from "./common.js"; +import { DEFAULT_HEALTHCHECK, escapeComposeValue } from "./common.js"; /** * Mnemonic of the devnet test accounts, used by the node to sign transactions @@ -151,8 +151,12 @@ const service = (options: ServiceOptions): Service => { CARTESI_SNAPSHOTS_DIR: "/var/lib/cartesi-rollups-node/snapshots", }; - const hostVars = getNodeAllowedVariables( - options.cartesiEnvironmentVariables, + // the host values are the user's own and may hold a `$`, e.g. in a + // password, which compose must not interpolate + const hostVars = Object.fromEntries( + Object.entries( + getNodeAllowedVariables(options.cartesiEnvironmentVariables), + ).map(([name, value]) => [name, escapeComposeValue(value)]), ); // Merge default and host environment variables, with host variables taking precedence diff --git a/apps/cli/tests/integration/commands/run.test.ts b/apps/cli/tests/integration/commands/run.test.ts index 46055685..aa2e851b 100644 --- a/apps/cli/tests/integration/commands/run.test.ts +++ b/apps/cli/tests/integration/commands/run.test.ts @@ -20,7 +20,7 @@ describe("run", () => { afterAll(() => fs.removeSync(dir)); - const dryRun = (args: string[]) => + const dryRun = (args: string[], env?: Record) => execa( "node", [ @@ -31,7 +31,7 @@ describe("run", () => { TEST_RUNTIME_VERSION, ...args, ], - { all: true, cwd: dir, reject: false }, + { all: true, cwd: dir, env, reject: false }, ); describe("--default-block", () => { @@ -57,4 +57,19 @@ describe("run", () => { expect(all).not.toContain("CARTESI_BLOCKCHAIN_DEFAULT_BLOCK"); }); }); + + describe("host variables", () => { + // compose prints the value it would pass as `$$`, the way it reads it + // back; unescaped, it would have replaced `$t` with nothing + it("should keep the $ in a forwarded node variable", async () => { + const { all, exitCode } = await dryRun([], { + CARTESI_BLOCKCHAIN_HTTP_AUTHORIZATION: "Bearer s3cr$t", + }); + + expect(exitCode, all).toBe(0); + expect(all).toContain( + "CARTESI_BLOCKCHAIN_HTTP_AUTHORIZATION: Bearer s3cr$$t", + ); + }); + }); }); diff --git a/apps/cli/tests/unit/compose/anvil.test.ts b/apps/cli/tests/unit/compose/anvil.test.ts new file mode 100644 index 00000000..55e704c4 --- /dev/null +++ b/apps/cli/tests/unit/compose/anvil.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "bun:test"; +import buildAnvilCompose from "../../../src/compose/anvil.js"; + +describe("Compose anvil service", () => { + // compose interpolates `$name` in the file it reads, so the URL must reach + // it as `$$`, which compose turns back into `$` + it("should escape the $ in the fork url for compose", () => { + const compose = buildAnvilCompose({ + forkConfig: { + blockNumber: 10n, + chainId: 11155111, + url: "https://rpc.example.com/v2/k$y", + }, + }); + + expect(compose.services?.anvil?.command).toEqual([ + "anvil", + "--chain-id", + "31337", + "--block-time", + "2", + "--fork-url", + "https://rpc.example.com/v2/k$$y", + "--fork-block-number", + "10", + ]); + }); +}); diff --git a/apps/cli/tests/unit/compose/node.test.ts b/apps/cli/tests/unit/compose/node.test.ts index 05d2d72a..f4fc035c 100644 --- a/apps/cli/tests/unit/compose/node.test.ts +++ b/apps/cli/tests/unit/compose/node.test.ts @@ -339,6 +339,26 @@ describe("Compose node service", () => { expect(env).toHaveProperty("CARTESI_LOG_LEVEL", "warn"); }); + // compose interpolates `$name` in the file it reads, so a host value + // must reach it as `$$`, which compose turns back into `$` + it("should escape the $ in host variables for compose", () => { + const compose = buildNodeCompose({ + ...baseOptions, + cartesiEnvironmentVariables: { + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: + "https://alice:s3cr$t@rpc.example.com/$key", + CARTESI_LOG_LEVEL: "warn", + }, + }); + const env = compose.services?.rollups_node?.environment ?? {}; + + expect(env).toHaveProperty( + "CARTESI_BLOCKCHAIN_HTTP_ENDPOINT", + "https://alice:s3cr$$t@rpc.example.com/$$key", + ); + expect(env).toHaveProperty("CARTESI_LOG_LEVEL", "warn"); + }); + it("should build the database connection string from provided property", () => { const compose = buildNodeCompose({ databasePassword: "specialsecret", From 515652376ef66893e9695092dd0da2f5491d846d Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 10:47:48 +0000 Subject: [PATCH 10/17] fix(cli): copy node key files into the node container --- .changeset/quick-dancers-pick.md | 5 + apps/cli/src/compose/node.ts | 96 +++++++++++++++---- apps/cli/src/compose/secrets.ts | 71 ++++++++++++++ apps/cli/src/exec/rollups.ts | 2 + apps/cli/src/validations.ts | 25 +++++ .../tests/integration/commands/run.test.ts | 31 ++++++ .../tests/integration/node-startup.test.ts | 93 ++++++++++++++++++ apps/cli/tests/unit/compose/node.test.ts | 80 ++++++++++++++++ apps/cli/tests/unit/validations.test.ts | 55 ++++++++++- 9 files changed, 440 insertions(+), 18 deletions(-) create mode 100644 .changeset/quick-dancers-pick.md create mode 100644 apps/cli/src/compose/secrets.ts diff --git a/.changeset/quick-dancers-pick.md b/.changeset/quick-dancers-pick.md new file mode 100644 index 00000000..b341b37b --- /dev/null +++ b/.changeset/quick-dancers-pick.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +`CARTESI_*_FILE` key files set on the host now reach the node container as copies the node can read. Before, only the path was forwarded and the node couldn't read the file. diff --git a/apps/cli/src/compose/node.ts b/apps/cli/src/compose/node.ts index 5d4c4bde..00d1e9f8 100644 --- a/apps/cli/src/compose/node.ts +++ b/apps/cli/src/compose/node.ts @@ -8,6 +8,7 @@ import { } from "../contracts.js"; import type { ComposeFile, Config, Service } from "../types/compose.js"; import { DEFAULT_HEALTHCHECK, escapeComposeValue } from "./common.js"; +import { type FileSecret, fileSecret, secretInitService } from "./secrets.js"; /** * Mnemonic of the devnet test accounts, used by the node to sign transactions @@ -105,8 +106,40 @@ export const getNodeAllowedVariables = ( return allowedVars; }; +/** + * The allowed variables that name a file the node reads its keys from. + * Forwarding only the path would leave the node without the file, so each + * one is copied into the container instead. + */ +export const nodeSecretFileVariables = nodeAllowedEnvironmentVariables.filter( + (name) => name.endsWith("_FILE"), +); + +/** Service that copies the key files for the node, and its volume. */ +const SECRET_INIT_SERVICE = "node-secret-init"; +const SECRETS_VOLUME = "node_secrets"; + +/** + * Returns the key files named in the host variables, each with the variable + * that points the node at its copy. + */ +const nodeFileSecrets = ( + cartesiVars?: CartesiEnvironmentVariables, +): (FileSecret & { variable: string })[] => { + const allowedVars = getNodeAllowedVariables(cartesiVars); + return nodeSecretFileVariables.flatMap((variable) => { + const hostPath = allowedVars[variable]; + return hostPath === undefined + ? [] + : [{ variable, ...fileSecret(variable.toLowerCase(), hostPath) }]; + }); +}; + // Rollups Node service -const service = (options: ServiceOptions): Service => { +const service = ( + options: ServiceOptions, + fileSecrets: (FileSecret & { variable: string })[], +): Service => { const databasePassword = options.databasePassword; const databaseHost = options.databaseHost ?? "database"; const databasePort = options.databasePort ?? 5432; @@ -159,6 +192,11 @@ const service = (options: ServiceOptions): Service => { ).map(([name, value]) => [name, escapeComposeValue(value)]), ); + // the key files reach the node as secrets, at a path inside the container + for (const { variable, target } of fileSecrets) { + hostVars[variable] = target; + } + // 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 @@ -170,6 +208,11 @@ const service = (options: ServiceOptions): Service => { depends_on: { database: { condition: "service_healthy" }, anvil: { condition: "service_healthy" }, + ...(fileSecrets.length > 0 && { + [SECRET_INIT_SERVICE]: { + condition: "service_completed_successfully", + }, + }), }, deploy: { resources: { @@ -194,7 +237,12 @@ const service = (options: ServiceOptions): Service => { environment: { ...environmentVariables, }, - volumes: ["./.cartesi:/var/lib/cartesi-rollups-node/snapshots:ro"], + volumes: [ + "./.cartesi:/var/lib/cartesi-rollups-node/snapshots:ro", + ...(fileSecrets.length > 0 + ? [`${SECRETS_VOLUME}:/run/secrets:ro`] + : []), + ], }; }; @@ -243,19 +291,33 @@ http: }; }; -export default (options: ServiceOptions): ComposeFile => ({ - services: { - proxy: { - configs: [ - { - source: "rollups_node_proxy", - target: "/etc/traefik/conf.d/rollups-node.yaml", - }, - ], +export default (options: ServiceOptions): ComposeFile => { + const fileSecrets = nodeFileSecrets(options.cartesiEnvironmentVariables); + + return { + services: { + proxy: { + configs: [ + { + source: "rollups_node_proxy", + target: "/etc/traefik/conf.d/rollups-node.yaml", + }, + ], + }, + rollups_node: service(options, fileSecrets), + ...(fileSecrets.length > 0 && { + [SECRET_INIT_SERVICE]: secretInitService({ + imageTag: options.imageTag ?? "latest", + secrets: fileSecrets, + volume: SECRETS_VOLUME, + }), + }), + }, + configs: { + rollups_node_proxy: proxy(), }, - rollups_node: service(options), - }, - configs: { - rollups_node_proxy: proxy(), - }, -}); + ...(fileSecrets.length > 0 && { + volumes: { [SECRETS_VOLUME]: {} }, + }), + }; +}; diff --git a/apps/cli/src/compose/secrets.ts b/apps/cli/src/compose/secrets.ts new file mode 100644 index 00000000..ad1003cc --- /dev/null +++ b/apps/cli/src/compose/secrets.ts @@ -0,0 +1,71 @@ +import type { Service, VolumeMount } from "../types/compose.js"; +import { escapeComposeValue } from "./common.js"; + +/** + * User and group the node runs as, created in the rollups-runtime image + * (`packages/sdk/Dockerfile`). Named, not numbered: the init service runs in + * that same image, so `install` resolves the ids the image actually has. + */ +export const RUNTIME_USER = "cartesi"; + +/** + * A host key file a service reads from `target`. A bind mount would keep the + * host's owner and mode, which the service's user may not be able to read, + * so an init service copies the file into a volume, owned by that user. + */ +export type FileSecret = { + name: string; + /** read-only bind mount of the host file, into the init service */ + source: VolumeMount; + /** shell line that installs the copy, in the init service */ + install: string; + /** path of the copy in the service that reads it */ + target: string; +}; + +/** + * Declares a host file the runtime user reads as `/run/secrets/`. + * @param name file name under `/run/secrets` + * @param hostPath absolute, or relative to the project directory + */ +export const fileSecret = (name: string, hostPath: string): FileSecret => ({ + name, + source: { + type: "bind", + source: escapeComposeValue(hostPath), + target: `/src/${name}`, + read_only: true, + // a missing file must fail, not become an empty directory + bind: { create_host_path: false }, + }, + install: `install -o ${RUNTIME_USER} -g ${RUNTIME_USER} -m 0400 /src/${name} /dst/${name}`, + target: `/run/secrets/${name}`, +}); + +/** + * Returns the service that installs the secrets into `volume`. The services + * that read them wait for it to complete and mount `volume` at + * `/run/secrets`, read-only. + */ +export const secretInitService = (options: { + imageTag: string; + secrets: FileSecret[]; + volume: string; +}): Service => ({ + image: `cartesi/rollups-runtime:${options.imageTag}`, + user: "root", + network_mode: "none", + restart: "no", + entrypoint: ["sh", "-ec"], + command: [ + [ + // copies of a previous run must not outlive their host files + "find /dst -mindepth 1 -delete", + ...options.secrets.map(({ install }) => install), + ].join("\n"), + ], + volumes: [ + ...options.secrets.map(({ source }) => source), + `${options.volume}:/dst`, + ], +}); diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index 5066a91c..ec3c9bcf 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -32,6 +32,7 @@ import paymaster from "../compose/paymaster.js"; import proxy from "../compose/proxy.js"; import type { WithdrawalConfig } from "../config.js"; import type { ForkConfig } from "../types/chain.js"; +import { assertSecretFiles } from "../validations.js"; import type { AccountsDriveConfig } from "./cartesi-rollups-machine-tool.js"; import { execNodeCommand } from "./node-container.js"; @@ -495,6 +496,7 @@ export const startEnvironment = async (options: { // Load all environment variables from the host that start with CARTESI_. const hostVars = getCartesiEnvironmentVariables(); + assertSecretFiles(hostVars); const files = [ anvil({ diff --git a/apps/cli/src/validations.ts b/apps/cli/src/validations.ts index 83770d9a..de1f3c30 100644 --- a/apps/cli/src/validations.ts +++ b/apps/cli/src/validations.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { createPublicClient, http, @@ -17,6 +18,8 @@ import { etherPortalConfig, inputBoxConfig, } from "./contracts"; +import type { CartesiEnvironmentVariables } from "./base"; +import { nodeSecretFileVariables } from "./compose/node"; import ForkChainValidationError from "./errors/ForkChainValidationError"; import UnsupportedForkChainError from "./errors/UnsupportedForkChainError"; import type { ForkConfig } from "./types/chain"; @@ -284,3 +287,25 @@ export const assertForkConfig = async ( return true; }; + +/** + * Asserts that each key file the host names for the node is a readable file, + * so a wrong path fails before any container starts. The error names the + * variable and the path, never the file's content. + */ +export const assertSecretFiles = (env: CartesiEnvironmentVariables) => { + for (const name of nodeSecretFileVariables) { + const file = env[name]; + if (file === undefined) { + continue; + } + try { + if (!fs.statSync(file).isFile()) { + throw new Error("not a file"); + } + fs.accessSync(file, fs.constants.R_OK); + } catch { + throw new Error(`${name} must name a readable file: "${file}"`); + } + } +}; diff --git a/apps/cli/tests/integration/commands/run.test.ts b/apps/cli/tests/integration/commands/run.test.ts index aa2e851b..bf6c5dc9 100644 --- a/apps/cli/tests/integration/commands/run.test.ts +++ b/apps/cli/tests/integration/commands/run.test.ts @@ -72,4 +72,35 @@ describe("run", () => { ); }); }); + + describe("key files", () => { + it("should hand the node a copy of a key file named on the host", async () => { + fs.writeFileSync(path.join(dir, "claimer.key"), "0xsecret\n"); + + const { all, exitCode } = await dryRun([], { + CARTESI_AUTH_PRIVATE_KEY_FILE: "claimer.key", + }); + + expect(exitCode, all).toBe(0); + expect(all).toContain( + "CARTESI_AUTH_PRIVATE_KEY_FILE: /run/secrets/cartesi_auth_private_key_file", + ); + expect(all).toContain("node-secret-init:"); + expect(all).toContain("condition: service_completed_successfully"); + expect(all).toContain(path.join(dir, "claimer.key")); + expect(all).not.toContain("0xsecret"); + }); + + it("should reject a missing key file before rendering", async () => { + const { all, exitCode } = await dryRun([], { + CARTESI_AUTH_PRIVATE_KEY_FILE: "missing.key", + }); + + expect(exitCode).not.toBe(0); + expect(all).toContain( + 'CARTESI_AUTH_PRIVATE_KEY_FILE must name a readable file: "missing.key"', + ); + expect(all).not.toContain("services:"); + }); + }); }); diff --git a/apps/cli/tests/integration/node-startup.test.ts b/apps/cli/tests/integration/node-startup.test.ts index 57b0dde1..e82781d5 100644 --- a/apps/cli/tests/integration/node-startup.test.ts +++ b/apps/cli/tests/integration/node-startup.test.ts @@ -4,6 +4,10 @@ import getPort, { portNumbers } from "get-port"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { toHex } from "viem"; +import { mnemonicToAccount } from "viem/accounts"; +import { DEVNET_MNEMONIC } from "../../src/compose/node.js"; +import { execNodeCommand } from "../../src/exec/node-container.js"; import { startEnvironment, stopEnvironment, @@ -17,6 +21,7 @@ import { TEST_RUNTIME_VERSION } from "./config.js"; // is needed: the node serves /livez before any application is deployed. describe("rollups node startup", () => { const projectName = `cli-node-startup-${process.pid}`; + const keysProjectName = `cli-node-startup-keys-${process.pid}`; let port: number; let directory: string; let originalCwd: string; @@ -32,6 +37,7 @@ describe("rollups node startup", () => { afterAll(async () => { try { await stopEnvironment({ projectName }); + await stopEnvironment({ projectName: keysProjectName }); } finally { process.chdir(originalCwd); fs.rmSync(directory, { recursive: true, force: true }); @@ -67,4 +73,91 @@ describe("rollups node startup", () => { // proves the signer was used and not merely that the node booted expect(stdout).toContain("PRT submitter identity"); }, 300_000); + + // the node runs as uid 102 and a key file is usually its owner's alone, so + // the CLI hands the node a copy it owns; a PRT identity of the key file's + // account, not the mnemonic's account 0, proves the node read it + it("should reach a healthy node with owner-only key files", async () => { + const writeKey = (name: string, addressIndex: number) => { + const account = mnemonicToAccount(DEVNET_MNEMONIC, { + addressIndex, + }); + const privateKey = account.getHdKey().privateKey; + if (!privateKey) { + throw new Error(`no private key for account ${addressIndex}`); + } + const file = path.join(directory, name); + fs.writeFileSync(file, `${toHex(privateKey)}\n`, { mode: 0o600 }); + return { address: account.address, file }; + }; + const claimer = writeKey("claimer.key", 0); + const prt = writeKey("prt.key", 6); + const keyVars = { + CARTESI_AUTH_KIND: "private_key_file", + CARTESI_AUTH_PRIVATE_KEY_FILE: claimer.file, + CARTESI_PRT_AUTH_KIND: "private_key_file", + CARTESI_PRT_AUTH_PRIVATE_KEY_FILE: prt.file, + }; + const keysPort = await getPort({ port: portNumbers(16751, 16761) }); + + Object.assign(process.env, keyVars); + try { + await startEnvironment({ + blockTime: 1, + defaultBlock: "latest", + detach: true, + dryRun: false, + port: keysPort, + projectName: keysProjectName, + prt: false, + runtimeVersion: TEST_RUNTIME_VERSION, + services: [], + verbose: false, + }); + } finally { + for (const name of Object.keys(keyVars)) { + delete process.env[name]; + } + } + + await waitHealthyEnvironment({ + port: keysPort, + projectName: keysProjectName, + services: [], + }); + + const { stdout } = await execa("docker", [ + "compose", + "--project-name", + keysProjectName, + "logs", + "rollups_node", + ]); + + expect(stdout).toMatch( + new RegExp(`PRT submitter identity.*${prt.address}`, "i"), + ); + + // the copy is what makes an owner-only file readable: owned by the + // node's user, read-only, and the host file is left as it was + const stat = (file: string) => + execNodeCommand({ + command: ["stat", "-c", "%U:%G %a", file], + projectName: keysProjectName, + }); + expect(await stat("/run/secrets/cartesi_auth_private_key_file")).toBe( + "cartesi:cartesi 400", + ); + expect( + await stat("/run/secrets/cartesi_prt_auth_private_key_file"), + ).toBe("cartesi:cartesi 400"); + + // the init service mounts the host files read-only and copies them, + // so their owner and mode don't change + for (const { file } of [claimer, prt]) { + const { uid, mode } = fs.statSync(file); + expect(uid).toBe(process.getuid?.() ?? uid); + expect(mode & 0o777).toBe(0o600); + } + }, 300_000); }); diff --git a/apps/cli/tests/unit/compose/node.test.ts b/apps/cli/tests/unit/compose/node.test.ts index f4fc035c..95e05a50 100644 --- a/apps/cli/tests/unit/compose/node.test.ts +++ b/apps/cli/tests/unit/compose/node.test.ts @@ -359,6 +359,86 @@ describe("Compose node service", () => { expect(env).toHaveProperty("CARTESI_LOG_LEVEL", "warn"); }); + // only the path would reach the container otherwise, and the node + // couldn't open the file + // a bind mount would keep the host's owner and mode, which the node's + // user may not read, so an init service hands the node copies it owns + it("should copy the key files the host names for the node", () => { + const compose = buildNodeCompose({ + ...baseOptions, + cartesiEnvironmentVariables: { + CARTESI_AUTH_KIND: "private_key_file", + CARTESI_AUTH_PRIVATE_KEY_FILE: "secrets/claimer.key", + CARTESI_PRT_AUTH_MNEMONIC_FILE: "/keys/prt$1", + }, + }); + const init = compose.services?.["node-secret-init"]; + const service = compose.services?.rollups_node; + + expect(init).toMatchObject({ + image: "cartesi/rollups-runtime:latest", + network_mode: "none", + restart: "no", + user: "root", + }); + expect(init?.command).toEqual([ + [ + "find /dst -mindepth 1 -delete", + "install -o cartesi -g cartesi -m 0400 /src/cartesi_auth_private_key_file /dst/cartesi_auth_private_key_file", + "install -o cartesi -g cartesi -m 0400 /src/cartesi_prt_auth_mnemonic_file /dst/cartesi_prt_auth_mnemonic_file", + ].join("\n"), + ]); + expect(init?.volumes).toEqual([ + { + type: "bind", + source: "secrets/claimer.key", + target: "/src/cartesi_auth_private_key_file", + read_only: true, + bind: { create_host_path: false }, + }, + { + type: "bind", + source: "/keys/prt$$1", + target: "/src/cartesi_prt_auth_mnemonic_file", + read_only: true, + bind: { create_host_path: false }, + }, + "node_secrets:/dst", + ]); + expect(compose.volumes).toEqual({ node_secrets: {} }); + expect(service?.depends_on).toMatchObject({ + "node-secret-init": { + condition: "service_completed_successfully", + }, + }); + expect(service?.volumes).toContain("node_secrets:/run/secrets:ro"); + expect(service?.environment).toMatchObject({ + CARTESI_AUTH_KIND: "private_key_file", + CARTESI_AUTH_PRIVATE_KEY_FILE: + "/run/secrets/cartesi_auth_private_key_file", + CARTESI_PRT_AUTH_MNEMONIC_FILE: + "/run/secrets/cartesi_prt_auth_mnemonic_file", + }); + }); + + it("should copy nothing when the host names no key file", () => { + const compose = buildNodeCompose({ + ...baseOptions, + cartesiEnvironmentVariables: { + CARTESI_AUTH_PRIVATE_KEY: "0xsecret", + }, + }); + + expect(compose.services).not.toHaveProperty("node-secret-init"); + expect(compose.volumes).toBeUndefined(); + expect( + compose.services?.rollups_node?.depends_on, + ).not.toHaveProperty("node-secret-init"); + expect(compose.services?.rollups_node?.volumes).toEqual([ + "./.cartesi:/var/lib/cartesi-rollups-node/snapshots:ro", + ]); + }); + it("should build the database connection string from provided property", () => { const compose = buildNodeCompose({ databasePassword: "specialsecret", diff --git a/apps/cli/tests/unit/validations.test.ts b/apps/cli/tests/unit/validations.test.ts index f75188e2..bf1f584a 100644 --- a/apps/cli/tests/unit/validations.test.ts +++ b/apps/cli/tests/unit/validations.test.ts @@ -1,5 +1,6 @@ import { afterAll, + beforeAll, beforeEach, describe, expect, @@ -7,6 +8,9 @@ import { mock, spyOn, } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import * as viem from "viem"; import { celo, sepolia } from "viem/chains"; import { @@ -21,7 +25,7 @@ import { import ForkChainValidationError from "../../src/errors/ForkChainValidationError"; import UnsupportedForkChainError from "../../src/errors/UnsupportedForkChainError"; import type { ForkConfig } from "../../src/types/chain"; -import { assertForkConfig } from "../../src/validations"; +import { assertForkConfig, assertSecretFiles } from "../../src/validations"; const mockGetBlockNumber = mock().mockResolvedValue(12345n); const mockGetCode = mock().mockResolvedValue("0x1234"); @@ -311,4 +315,53 @@ describe("validations", () => { expect(mockGetCode.mock.calls[0][0].blockNumber).toBe(999n); }); }); + + describe("assertSecretFiles", () => { + let dir: string; + let keyFile: string; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "secrets-")); + keyFile = path.join(dir, "key"); + fs.writeFileSync(keyFile, "0xsecret\n"); + }); + + afterAll(() => fs.rmSync(dir, { recursive: true })); + + it("should accept readable key files", () => { + expect(() => + assertSecretFiles({ + CARTESI_AUTH_PRIVATE_KEY_FILE: keyFile, + CARTESI_PRT_AUTH_MNEMONIC_FILE: keyFile, + }), + ).not.toThrow(); + }); + + it("should ignore the variables that name no file", () => { + expect(() => + assertSecretFiles({ + CARTESI_AUTH_PRIVATE_KEY: "0xsecret", + CARTESI_SNAPSHOTS_DIR: dir, + }), + ).not.toThrow(); + }); + + it("should name the variable and path of a missing file", () => { + const missing = path.join(dir, "missing"); + + expect(() => + assertSecretFiles({ + CARTESI_PRT_AUTH_PRIVATE_KEY_FILE: missing, + }), + ).toThrow( + `CARTESI_PRT_AUTH_PRIVATE_KEY_FILE must name a readable file: "${missing}"`, + ); + }); + + it("should reject a directory", () => { + expect(() => + assertSecretFiles({ CARTESI_AUTH_MNEMONIC_FILE: dir }), + ).toThrow("CARTESI_AUTH_MNEMONIC_FILE must name a readable file"); + }); + }); }); From 96d2ad6bae0ef4829ea2e4aeec88d294abd2d98a Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 11:55:41 +0000 Subject: [PATCH 11/17] feat(cli): reject unsupported blockchain endpoints early --- .changeset/stale-clubs-end.md | 5 + apps/cli/src/exec/rollups.ts | 3 +- apps/cli/src/validations.ts | 129 ++++++++++++++++++ .../tests/integration/commands/run.test.ts | 28 ++++ apps/cli/tests/unit/validations.test.ts | 78 ++++++++++- 5 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 .changeset/stale-clubs-end.md diff --git a/.changeset/stale-clubs-end.md b/.changeset/stale-clubs-end.md new file mode 100644 index 00000000..a76fe6d5 --- /dev/null +++ b/.changeset/stale-clubs-end.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": minor +--- + +`cartesi run` now rejects a `CARTESI_BLOCKCHAIN_HTTP_ENDPOINT` the rollups-node can't use before starting anything. It must be an http(s) URL with a DNS name or IP address. The error doesn't show the value, which may hold credentials. diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index ec3c9bcf..f1709fb3 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -32,7 +32,7 @@ import paymaster from "../compose/paymaster.js"; import proxy from "../compose/proxy.js"; import type { WithdrawalConfig } from "../config.js"; import type { ForkConfig } from "../types/chain.js"; -import { assertSecretFiles } from "../validations.js"; +import { assertBlockchainEndpoint, assertSecretFiles } from "../validations.js"; import type { AccountsDriveConfig } from "./cartesi-rollups-machine-tool.js"; import { execNodeCommand } from "./node-container.js"; @@ -496,6 +496,7 @@ export const startEnvironment = async (options: { // Load all environment variables from the host that start with CARTESI_. const hostVars = getCartesiEnvironmentVariables(); + assertBlockchainEndpoint(hostVars); assertSecretFiles(hostVars); const files = [ diff --git a/apps/cli/src/validations.ts b/apps/cli/src/validations.ts index de1f3c30..1fab8cca 100644 --- a/apps/cli/src/validations.ts +++ b/apps/cli/src/validations.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { isIPv6 } from "node:net"; import { createPublicClient, http, @@ -309,3 +310,131 @@ export const assertSecretFiles = (env: CartesiEnvironmentVariables) => { } } }; + +/** Whether every `%` in `value` starts a `%XX` escape. */ +const hasValidEscapes = (value: string): boolean => + !/%(?![0-9A-Fa-f]{2})/.test(value); + +/** + * Whether `name` is a host name Go's `url.Parse` accepts: no character it + * would have to escape, and `%XX` only for non-ASCII bytes or `%25`. + */ +const isValidHostName = (name: string): boolean => { + for (let i = 0; i < name.length; i++) { + const c = name[i]; + if (c === "%") { + const hex = name.slice(i + 1, i + 3); + if (!/^[0-9A-Fa-f]{2}$/.test(hex)) { + return false; + } + if (Number.parseInt(hex[0], 16) < 8 && hex !== "25") { + return false; + } + i += 2; + } else if ( + c.charCodeAt(0) < 0x80 && + !/[A-Za-z0-9\-._~!$&'()*+,;="<>[\]]/.test(c) + ) { + return false; + } + } + return true; +}; + +/** Whether `host` (`name[:port]` or `[ipv6][:port]`) has a non-empty name. */ +const isValidHost = (host: string): boolean => { + let name: string; + let port: string; + if (host.startsWith("[")) { + const close = host.lastIndexOf("]"); + if (close < 0) { + return false; + } + name = host.slice(1, close); + port = host.slice(close + 1); + // an IPv6 zone is escaped as %25, e.g. [fe80::1%25en0] + const zoneAt = name.indexOf("%25"); + const address = zoneAt < 0 ? name : name.slice(0, zoneAt); + if (!isIPv6(address) || !hasValidEscapes(name)) { + return false; + } + } else { + const colonAt = host.indexOf(":"); + if (colonAt !== host.lastIndexOf(":")) { + return false; + } + name = colonAt < 0 ? host : host.slice(0, colonAt); + port = colonAt < 0 ? "" : host.slice(colonAt); + if (!isValidHostName(name)) { + return false; + } + } + return name !== "" && /^(:[0-9]*)?$/.test(port); +}; + +/** + * Whether `raw` is an endpoint the rollups-node accepts: Go's `url.Parse` + * succeeds on it, its scheme is http or https and it has a host name. Mirrors + * Go rather than WHATWG `URL`, which accepts and rejects other URLs. + */ +export const isValidHttpEndpoint = (raw: string): boolean => { + // Go rejects control characters; surrounding whitespace is a mistake + const isControl = (c: string) => c < " " || c === "\x7f"; + if (raw !== raw.trim() || [...raw].some(isControl)) { + return false; + } + const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(raw); + if (!scheme || !["http", "https"].includes(scheme[1].toLowerCase())) { + return false; + } + let rest = raw.slice(scheme[0].length); + + // Go checks the escapes of the fragment, but not the query + const hashAt = rest.indexOf("#"); + if (hashAt >= 0) { + if (!hasValidEscapes(rest.slice(hashAt + 1))) { + return false; + } + rest = rest.slice(0, hashAt); + } + const queryAt = rest.indexOf("?"); + if (queryAt >= 0) { + rest = rest.slice(0, queryAt); + } + + // without //, the URL has no authority and so no host + if (!rest.startsWith("//")) { + return false; + } + rest = rest.slice(2); + const slashAt = rest.indexOf("/"); + const authority = slashAt < 0 ? rest : rest.slice(0, slashAt); + const path = slashAt < 0 ? "" : rest.slice(slashAt); + if (!hasValidEscapes(path)) { + return false; + } + + const atAt = authority.lastIndexOf("@"); + const userinfo = atAt < 0 ? "" : authority.slice(0, atAt); + if ( + !/^[A-Za-z0-9\-._:~!$&'()*+,;=%@]*$/.test(userinfo) || + !hasValidEscapes(userinfo) + ) { + return false; + } + return isValidHost(authority.slice(atAt + 1)); +}; + +/** + * Asserts that a blockchain endpoint set on the host is one the rollups-node + * accepts, so a wrong one fails before any container starts. The error never + * shows the value, which may hold credentials. + */ +export const assertBlockchainEndpoint = (env: CartesiEnvironmentVariables) => { + const endpoint = env.CARTESI_BLOCKCHAIN_HTTP_ENDPOINT; + if (endpoint !== undefined && !isValidHttpEndpoint(endpoint)) { + throw new Error( + "CARTESI_BLOCKCHAIN_HTTP_ENDPOINT must be an http(s) URL with a DNS name or IP address (redirects are not followed)", + ); + } +}; diff --git a/apps/cli/tests/integration/commands/run.test.ts b/apps/cli/tests/integration/commands/run.test.ts index bf6c5dc9..ee2f76a0 100644 --- a/apps/cli/tests/integration/commands/run.test.ts +++ b/apps/cli/tests/integration/commands/run.test.ts @@ -103,4 +103,32 @@ describe("run", () => { expect(all).not.toContain("services:"); }); }); + + describe("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT", () => { + it("should forward a supported endpoint to the node", async () => { + const { all, exitCode } = await dryRun([], { + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: "https://rpc.example.com/v1", + }); + + expect(exitCode, all).toBe(0); + expect(all).toContain( + "CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: https://rpc.example.com/v1", + ); + }); + + // the node only fails once its container starts, and hides the value + it("should reject an unsupported endpoint without showing it", async () => { + const { all, exitCode } = await dryRun([], { + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: + "wss://rpc.example.com/SECRET", + }); + + expect(exitCode).not.toBe(0); + expect(all).toContain( + "CARTESI_BLOCKCHAIN_HTTP_ENDPOINT must be an http(s) URL", + ); + expect(all).not.toContain("SECRET"); + expect(all).not.toContain("services:"); + }); + }); }); diff --git a/apps/cli/tests/unit/validations.test.ts b/apps/cli/tests/unit/validations.test.ts index bf1f584a..6bb6f20d 100644 --- a/apps/cli/tests/unit/validations.test.ts +++ b/apps/cli/tests/unit/validations.test.ts @@ -25,7 +25,11 @@ import { import ForkChainValidationError from "../../src/errors/ForkChainValidationError"; import UnsupportedForkChainError from "../../src/errors/UnsupportedForkChainError"; import type { ForkConfig } from "../../src/types/chain"; -import { assertForkConfig, assertSecretFiles } from "../../src/validations"; +import { + assertBlockchainEndpoint, + assertForkConfig, + assertSecretFiles, +} from "../../src/validations"; const mockGetBlockNumber = mock().mockResolvedValue(12345n); const mockGetCode = mock().mockResolvedValue("0x1234"); @@ -364,4 +368,76 @@ describe("validations", () => { ).toThrow("CARTESI_AUTH_MNEMONIC_FILE must name a readable file"); }); }); + + // the cases of the node's own tests (internal/httpclient/client_test.go and + // internal/config/http_endpoints_test.go), then the edges of Go's url.Parse; + // all checked against the alpha.14 node binary, which only differs in + // accepting trailing whitespace after the path + describe("assertBlockchainEndpoint", () => { + const marker = "SECRET"; + const expectedError = + "CARTESI_BLOCKCHAIN_HTTP_ENDPOINT must be an http(s) URL with a DNS name or IP address (redirects are not followed)"; + + it.each([ + "http://localhost:8545", + "https://u:p@example.com/path?q=key", + "http://[::1]:8545/rpc", + `http://user:${marker}@provider.example:8545/a%2Fb?key=${marker}&key=second`, + `https://user:${marker}@provider.example:8545/a%2Fb?key=${marker}&key=second`, + "HTTPS://provider.example", + "http://provider.example:", + "http://[fe80::1%25en0]:8545", + "http://us%41er@provider.example", + "http://ho%c3%a9st", + "http://provider.example?%zz", + ])("should accept %p", (endpoint) => { + expect(() => + assertBlockchainEndpoint({ + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: endpoint, + }), + ).not.toThrow(); + }); + + it.each([ + "", + `${marker}.sock`, + `wss://host/${marker}`, + `ws://host/${marker}`, + `ipc://host/${marker}`, + `file:///tmp/${marker}`, + `/tmp/${marker}`, + `https:///${marker}`, + `http:///${marker}`, + `http://:8545/${marker}`, + `https://:8545/${marker}`, + `http:${marker}`, + `http://host/%${marker}`, + `https://provider.example/%${marker}`, + `http://host:${marker}`, + `http://user:${marker}@/`, + `http://user:${marker}@host:8545:8545`, + `http://[${marker}]:8545`, + `http://[::1/${marker}`, + `http://[fe80::1%en0]/${marker}`, + `http://ho st/${marker}`, + `http://ho%20st/${marker}`, + `http://us er:${marker}@host`, + `http://host/${marker}#%zz`, + ` http://host/${marker}`, + `http://host/${marker} `, + `http://host/${marker}\n`, + ])("should reject %p without showing it", (endpoint) => { + const assert = () => + assertBlockchainEndpoint({ + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: endpoint, + }); + + expect(assert).toThrow(expectedError); + expect(assert).not.toThrow(marker); + }); + + it("should leave the endpoint to the CLI when the host sets none", () => { + expect(() => assertBlockchainEndpoint({})).not.toThrow(); + }); + }); }); From fb200933cfc6bb425f59b8a2069d54c84aab4e72 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 12:30:33 +0000 Subject: [PATCH 12/17] feat(cli): default send and deposit to devnet account 8 --- .changeset/mighty-otters-know.md | 5 + apps/cli/src/commands/deposit.ts | 5 +- apps/cli/src/commands/deposit/erc1155.ts | 6 +- apps/cli/src/commands/deposit/erc20.ts | 4 +- apps/cli/src/commands/deposit/erc721.ts | 4 +- apps/cli/src/commands/deposit/ether.ts | 4 +- apps/cli/src/commands/send.ts | 9 +- apps/cli/src/wallet.ts | 24 +++ .../tests/integration/commands/send.test.ts | 142 ++++++++++++++++++ apps/cli/tests/unit/wallet.test.ts | 35 ++++- 10 files changed, 224 insertions(+), 14 deletions(-) create mode 100644 .changeset/mighty-otters-know.md create mode 100644 apps/cli/tests/integration/commands/send.test.ts diff --git a/.changeset/mighty-otters-know.md b/.changeset/mighty-otters-know.md new file mode 100644 index 00000000..11b2a21f --- /dev/null +++ b/.changeset/mighty-otters-know.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": minor +--- + +`cartesi send` and `cartesi deposit` now default to devnet account 8 instead of account 0, which the node uses to sign its claims. Pass `--from` to pick another account. diff --git a/apps/cli/src/commands/deposit.ts b/apps/cli/src/commands/deposit.ts index 64d31e1a..e502960d 100644 --- a/apps/cli/src/commands/deposit.ts +++ b/apps/cli/src/commands/deposit.ts @@ -12,7 +12,10 @@ export const createDepositCommand = () => { const command = new Command("deposit") .description("Deposits an asset to the application") .configureHelp({ showGlobalOptions: true }) - .option("--from
", "input sender address") + .option( + "--from
", + "input sender address (default: devnet account 8)", + ) .option("--application
", "application address") .option( "--project-name ", diff --git a/apps/cli/src/commands/deposit/erc1155.ts b/apps/cli/src/commands/deposit/erc1155.ts index a57287bb..b40ba366 100644 --- a/apps/cli/src/commands/deposit/erc1155.ts +++ b/apps/cli/src/commands/deposit/erc1155.ts @@ -17,7 +17,7 @@ import { bigintInput, getInputApplicationAddress, } from "../../prompts.js"; -import { connect } from "../../wallet.js"; +import { connect, getDefaultSender } from "../../wallet.js"; import type { DepositCommandOpts } from "../deposit.js"; type ERC1155Token = { @@ -75,7 +75,7 @@ export const createErc1155SingleCommand = () => { const account = from && isAddress(from) ? getAddress(from) - : (await testClient.getAddresses())[0]; + : await getDefaultSender(testClient); const amount = amountString ? BigInt(amountString) @@ -253,7 +253,7 @@ export const createErc1155BatchCommand = () => { const account = from && isAddress(from) ? getAddress(from) - : (await testClient.getAddresses())[0]; + : await getDefaultSender(testClient); const tokenAbi = testMultiTokenAbi; diff --git a/apps/cli/src/commands/deposit/erc20.ts b/apps/cli/src/commands/deposit/erc20.ts index 70b775ed..8ba8f3e1 100755 --- a/apps/cli/src/commands/deposit/erc20.ts +++ b/apps/cli/src/commands/deposit/erc20.ts @@ -22,7 +22,7 @@ import { bigintInput, getInputApplicationAddress, } from "../../prompts.js"; -import { connect } from "../../wallet.js"; +import { connect, getDefaultSender } from "../../wallet.js"; import type { DepositCommandOpts } from "../deposit.js"; type ERC20Token = { @@ -93,7 +93,7 @@ export const createErc20Command = () => { const account = from && isAddress(from) ? getAddress(from) - : (await testClient.getAddresses())[0]; + : await getDefaultSender(testClient); const token = await parseToken({ testClient, diff --git a/apps/cli/src/commands/deposit/erc721.ts b/apps/cli/src/commands/deposit/erc721.ts index 896c51e7..4aaaf7e0 100755 --- a/apps/cli/src/commands/deposit/erc721.ts +++ b/apps/cli/src/commands/deposit/erc721.ts @@ -23,7 +23,7 @@ import { bigintInput, getInputApplicationAddress, } from "../../prompts.js"; -import { connect } from "../../wallet.js"; +import { connect, getDefaultSender } from "../../wallet.js"; import type { DepositCommandOpts } from "../deposit.js"; type ERC721Token = { @@ -93,7 +93,7 @@ export const createErc721Command = () => { const account = from && isAddress(from) ? getAddress(from) - : (await testClient.getAddresses())[0]; + : await getDefaultSender(testClient); const token = await parseToken({ testClient, diff --git a/apps/cli/src/commands/deposit/ether.ts b/apps/cli/src/commands/deposit/ether.ts index 2b0bf6c7..ad23054d 100755 --- a/apps/cli/src/commands/deposit/ether.ts +++ b/apps/cli/src/commands/deposit/ether.ts @@ -5,7 +5,7 @@ import { formatUnits, getAddress, isAddress, isHex, parseUnits } from "viem"; import { getProjectName } from "../../base.js"; import { etherPortalAbi, etherPortalAddress } from "../../contracts.js"; import { bigintInput, getInputApplicationAddress } from "../../prompts.js"; -import { connect } from "../../wallet.js"; +import { connect, getDefaultSender } from "../../wallet.js"; import type { DepositCommandOpts } from "../deposit.js"; export const createEtherCommand = () => { @@ -26,7 +26,7 @@ export const createEtherCommand = () => { const account = from && isAddress(from) ? getAddress(from) - : (await testClient.getAddresses())[0]; + : await getDefaultSender(testClient); // get dapp address from local node, or ask const application = await getInputApplicationAddress({ diff --git a/apps/cli/src/commands/send.ts b/apps/cli/src/commands/send.ts index 62e21ff9..debb653e 100755 --- a/apps/cli/src/commands/send.ts +++ b/apps/cli/src/commands/send.ts @@ -12,7 +12,7 @@ import { import { getProjectName } from "../base.js"; import { inputBoxAbi, inputBoxAddress } from "../contracts.js"; import { bytesInput, getInputApplicationAddress } from "../prompts.js"; -import { connect } from "../wallet.js"; +import { connect, getDefaultSender } from "../wallet.js"; const getInput = async ( input: string | undefined, @@ -114,7 +114,10 @@ export const createSendCommand = () => { const command = new Command("send") .description("Send input to the application") .argument("[input]", "input payload") - .option("--from
", "input sender address") + .option( + "--from
", + "input sender address (default: devnet account 8)", + ) .option("--application
", "application address") .addOption( new Option("--encoding ", "input encoding").choices([ @@ -142,7 +145,7 @@ export const createSendCommand = () => { const account = from && isAddress(from) ? getAddress(from) - : (await testClient.getAddresses())[0]; + : await getDefaultSender(testClient); // get dapp address from local node, or ask const applicationAddress = await getInputApplicationAddress({ diff --git a/apps/cli/src/wallet.ts b/apps/cli/src/wallet.ts index 595df6b4..a92de599 100644 --- a/apps/cli/src/wallet.ts +++ b/apps/cli/src/wallet.ts @@ -78,3 +78,27 @@ export const findMnemonicAccountIndex = (options: { } return undefined; }; + +/** + * Index of the devnet account that sends inputs and deposits when no `--from` + * is given. The node signs its claims with account 0 and the CLI uses account + * 1 as the guardian, so a sender of its own shares no nonces with either. + */ +export const DEFAULT_SENDER_INDEX = 8; + +/** + * Returns the default sender among the accounts the RPC exposes. + * @throws if the RPC exposes too few accounts + */ +export const getDefaultSender = async (client: { + getAddresses: () => Promise; +}): Promise
=> { + const addresses = await client.getAddresses(); + const sender = addresses[DEFAULT_SENDER_INDEX]; + if (sender === undefined) { + throw new Error( + `The RPC exposes ${addresses.length} accounts, so there is no devnet account ${DEFAULT_SENDER_INDEX} to send from; pass --from to choose the sender`, + ); + } + return sender; +}; diff --git a/apps/cli/tests/integration/commands/send.test.ts b/apps/cli/tests/integration/commands/send.test.ts new file mode 100644 index 00000000..c2a1c361 --- /dev/null +++ b/apps/cli/tests/integration/commands/send.test.ts @@ -0,0 +1,142 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import getPort, { portNumbers } from "get-port"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + type Address, + decodeFunctionData, + numberToHex, + zeroAddress, + zeroHash, +} from "viem"; +import { + inputBoxAbi, + inputBoxAddress, + inputsAbi, + selfHostedApplicationFactoryAbi, + selfHostedApplicationFactoryAddress, +} from "../../../src/contracts"; +import { + startEnvironment, + stopEnvironment, + waitHealthyEnvironment, +} from "../../../src/exec/rollups"; +import { TEST_RUNTIME_VERSION } from "../config"; +import { createAnvilClient, devnetAccount, runCli } from "../devnet"; + +// The InputBox only takes inputs for an application contract, but the node +// doesn't need to run it: each case creates one with the factory, with no +// machine behind it, and reads the sender back from the input the InputBox +// recorded. +describe("send", () => { + const projectName = `cli-send-${process.pid}`; + let directory: string; + let originalCwd: string; + + beforeAll(async () => { + originalCwd = process.cwd(); + directory = fs.mkdtempSync(path.join(os.tmpdir(), "send-")); + // compose files are written relative to the working directory + process.chdir(directory); + const port = await getPort({ port: portNumbers(16762, 16772) }); + await startEnvironment({ + blockTime: 1, + defaultBlock: "latest", + detach: true, + dryRun: false, + port, + projectName, + runtimeVersion: TEST_RUNTIME_VERSION, + services: [], + verbose: false, + }); + await waitHealthyEnvironment({ port, projectName, services: [] }); + }, 300_000); + + afterAll(async () => { + try { + await stopEnvironment({ projectName }); + } finally { + process.chdir(originalCwd); + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + /** Creates an application contract the node doesn't know of. */ + const createApplication = async (salt: number) => { + const anvil = await createAnvilClient(projectName); + const owner = devnetAccount(3); + const { request, result } = await anvil.simulateContract({ + abi: selfHostedApplicationFactoryAbi, + account: owner, + address: selfHostedApplicationFactoryAddress, + args: [ + owner, + 720n, + 0n, + zeroHash, + inputBoxAddress, + { + guardian: zeroAddress, + log2LeavesPerAccount: 0, + log2MaxNumOfAccounts: 0, + accountsDriveStartIndex: 0n, + withdrawalOutputBuilder: zeroAddress, + }, + numberToHex(salt, { size: 32 }), + ], + functionName: "deployContracts", + }); + const hash = await anvil.writeContract(request); + await anvil.waitForTransactionReceipt({ hash }); + return result[0]; + }; + + /** Sends an input with the CLI and returns its sender. */ + const sendInput = async (application: Address, args: string[] = []) => { + const { all, exitCode } = await runCli( + { appDir: directory, projectName }, + [ + "send", + "--application", + application, + "--encoding", + "string", + ...args, + "hello", + ], + ); + expect(exitCode, all).toBe(0); + + const anvil = await createAnvilClient(projectName); + const events = await anvil.getContractEvents({ + abi: inputBoxAbi, + address: inputBoxAddress, + args: { appContract: application }, + eventName: "InputAdded", + fromBlock: 0n, + }); + expect(events).toHaveLength(1); + const { args: input } = decodeFunctionData({ + abi: inputsAbi, + data: events[0].args.input ?? "0x", + }); + return input[2]; + }; + + // the node signs its claims with account 0, so inputs must not come from it + it("should send from devnet account 8 by default", async () => { + const application = await createApplication(1); + + expect(await sendInput(application)).toBe(devnetAccount(8)); + }); + + it("should send from the --from account", async () => { + const application = await createApplication(2); + + expect(await sendInput(application, ["--from", devnetAccount(2)])).toBe( + devnetAccount(2), + ); + }); +}); diff --git a/apps/cli/tests/unit/wallet.test.ts b/apps/cli/tests/unit/wallet.test.ts index 22a03f72..aa3a1573 100644 --- a/apps/cli/tests/unit/wallet.test.ts +++ b/apps/cli/tests/unit/wallet.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "bun:test"; import { DEVNET_MNEMONIC } from "../../src/compose/node"; -import { findMnemonicAccountIndex } from "../../src/wallet"; +import type { Address } from "viem"; +import { mnemonicToAccount } from "viem/accounts"; +import { + DEFAULT_SENDER_INDEX, + findMnemonicAccountIndex, + getDefaultSender, +} from "../../src/wallet"; describe("findMnemonicAccountIndex", () => { it("should find the index of a devnet account", () => { @@ -42,3 +48,30 @@ describe("findMnemonicAccountIndex", () => { ).toBeUndefined(); }); }); + +describe("getDefaultSender", () => { + const devnetAddresses = (count: number): Address[] => + Array.from( + { length: count }, + (_, addressIndex) => + mnemonicToAccount(DEVNET_MNEMONIC, { addressIndex }).address, + ); + const client = (addresses: Address[]) => ({ + getAddresses: async () => addresses, + }); + + it("should be devnet account 8", async () => { + expect(DEFAULT_SENDER_INDEX).toBe(8); + expect(await getDefaultSender(client(devnetAddresses(10)))).toBe( + "0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f", + ); + }); + + it("should ask for --from when the RPC exposes too few accounts", async () => { + await expect( + getDefaultSender(client(devnetAddresses(8))), + ).rejects.toThrow( + "The RPC exposes 8 accounts, so there is no devnet account 8 to send from; pass --from to choose the sender", + ); + }); +}); From 88eff278f47f1a75b2729a5d3bc9b5f8cf806a36 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 20:24:35 +0000 Subject: [PATCH 13/17] feat(cli): appoint prt sentries from cartesi.toml --- .changeset/witty-crews-repeat.md | 5 + apps/cli/src/commands/run.ts | 61 +++++ apps/cli/src/config.ts | 3 + apps/cli/src/config/defaults.ts | 1 + apps/cli/src/config/errors.ts | 10 + apps/cli/src/config/toml/prt.ts | 159 +++++++++++++ apps/cli/src/config/types.ts | 26 +++ apps/cli/src/exec/rollups.ts | 82 +++++++ apps/cli/src/sentries.ts | 149 ++++++++++++ apps/cli/src/wallet.ts | 31 +++ .../tests/integration/commands/prt.test.ts | 120 ++++++++++ .../tests/integration/commands/run.test.ts | 58 +++++ apps/cli/tests/integration/devnet.ts | 19 +- apps/cli/tests/unit/config.test.ts | 194 +++++++++++++++- apps/cli/tests/unit/config/fixtures/full.toml | 14 ++ .../unit/config/fixtures/prt/config.toml | 20 ++ apps/cli/tests/unit/exec/rollups.test.ts | 96 ++++++++ apps/cli/tests/unit/sentries.test.ts | 213 ++++++++++++++++++ apps/cli/tests/unit/wallet.test.ts | 33 +++ 19 files changed, 1290 insertions(+), 4 deletions(-) create mode 100644 .changeset/witty-crews-repeat.md create mode 100644 apps/cli/src/config/toml/prt.ts create mode 100644 apps/cli/src/sentries.ts create mode 100644 apps/cli/tests/integration/commands/prt.test.ts create mode 100644 apps/cli/tests/unit/config/fixtures/prt/config.toml create mode 100644 apps/cli/tests/unit/sentries.test.ts diff --git a/.changeset/witty-crews-repeat.md b/.changeset/witty-crews-repeat.md new file mode 100644 index 00000000..33a29a4d --- /dev/null +++ b/.changeset/witty-crews-repeat.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": minor +--- + +`cartesi run --prt` now deploys the application with the PRT sentries set in `cartesi.toml`. A `[prt]` section names an optional `sentry_manager` and `[[prt.sentries]]` entries, appointed on the consensus in that order. An entry with `type = "sling"` and no `address` gets one of the CLI's sentry pool, devnet accounts 20 to 29; an entry with only an `address` is appointed and run elsewhere. Without `--prt` the section is ignored. diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 09d00030..82909eb6 100755 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -41,6 +41,7 @@ import { waitHealthyEnvironment, } from "../exec/rollups.js"; import { keySelect } from "../prompts.js"; +import { describePrt, type ResolvedPrt, resolveSentries } from "../sentries.js"; import type { ForkConfig } from "../types/chain.js"; import { assertForkConfig } from "../validations.js"; import { getWithdrawalConfig } from "../withdrawal.js"; @@ -88,6 +89,7 @@ const shell = async (options: { salt: number; sdk: string; resolveWithdrawalConfig: () => WithdrawalConfig | undefined; + resolvePrt: () => ResolvedPrt | undefined; claimStagingPeriod: number; }) => { const { @@ -98,6 +100,7 @@ const shell = async (options: { prt, sdk, resolveWithdrawalConfig, + resolvePrt, claimStagingPeriod, } = options; @@ -184,6 +187,10 @@ const shell = async (options: { // the accounts drive may have moved with the rebuild, // resolved before undeploying in case it is now invalid const withdrawalConfig = resolveWithdrawalConfig(); + // a PRT redeploy creates a new consensus, with the + // sentries as they are in the config now + const prtConfig = resolvePrt(); + reportPrtWarnings(prtConfig, claimStagingPeriod); if (lastDeployment) { await undeploy({ projectName }); } @@ -193,6 +200,7 @@ const shell = async (options: { hash, projectName, prt, + prtConfig, salt: numberToHex(salt++, { size: 32 }), withdrawalConfig, claimStagingPeriod, @@ -234,12 +242,36 @@ const undeploy = async (options: { projectName: string }) => { progress.succeed(`${chalk.cyan(projectName)} undeployed`); }; +/** + * Warn about [prt] settings that are valid but have no effect. + */ +const reportPrtWarnings = ( + prtConfig: ResolvedPrt | undefined, + claimStagingPeriod: number, +) => { + for (const warning of prtConfig?.warnings ?? []) { + console.warn(chalk.yellow(`WARNING: ${warning}`)); + } + if ( + prtConfig && + prtConfig.sentries.length > 0 && + claimStagingPeriod === 0 + ) { + console.warn( + chalk.yellow( + "WARNING: with --claim-staging-period 0 a staged claim is accepted at once, so the sentries have no effect", + ), + ); + } +}; + const deploy = async (options: { consensus?: Address; epochLength: number; hash: Hex; projectName: string; prt?: boolean; + prtConfig?: ResolvedPrt; salt: Hex; withdrawalConfig?: WithdrawalConfig; claimStagingPeriod: number; @@ -250,11 +282,17 @@ const deploy = async (options: { hash, projectName, prt, + prtConfig, salt, withdrawalConfig, claimStagingPeriod, } = options; + // the sentries the consensus is deployed with, in ID order + for (const line of prtConfig ? describePrt(prtConfig) : []) { + console.log(line); + } + // deploy application to node (onchain and offchain) const progress = ora( `deploying ${chalk.cyan(hash)} as ${chalk.cyan(projectName)}`, @@ -267,6 +305,8 @@ const deploy = async (options: { projectName, prt, salt, + sentries: prtConfig?.sentries.map(({ address }) => address), + sentryManager: prtConfig?.sentryManager, snapshotPath: "/var/lib/cartesi-rollups-node/snapshots/image", withdrawalConfig, claimStagingPeriod, @@ -443,6 +483,25 @@ export const createRunCommand = () => { // get application configuration (e.g. use withdrawal config if present) const applicationConfig = getApplicationConfig(configFiles); + // [prt] is parsed either way, so a mistake in it fails early, but + // the node takes sentries only on a PRT deploy. Read again on a + // rebuild, like the withdrawal config. + const resolvePrt = () => { + const config = getApplicationConfig(configFiles); + return prt && config.prt + ? resolveSentries(config.prt) + : undefined; + }; + if (applicationConfig.prt && !prt) { + console.log( + chalk.dim("[prt] in cartesi.toml is ignored without --prt"), + ); + } + + // fail before starting the environment if the sentries can't be resolved + const prtConfig = resolvePrt(); + reportPrtWarnings(prtConfig, claimStagingPeriod); + // resolve port number, using the first free port in a range, unless explicitly set const port = options.port || @@ -527,6 +586,7 @@ export const createRunCommand = () => { hash, projectName, prt, + prtConfig, salt: numberToHex(salt++, { size: 32 }), claimStagingPeriod, withdrawalConfig, @@ -574,6 +634,7 @@ export const createRunCommand = () => { sdk: applicationConfig.sdk, claimStagingPeriod, resolveWithdrawalConfig, + resolvePrt, }); await shutdown(); } else { diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts index ad079583..4411a683 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config.ts @@ -5,6 +5,7 @@ import { DuplicateLabelError } from "./config/errors.js"; import { parseDrives } from "./config/toml/drives.js"; import { parseMachine } from "./config/toml/machine.js"; import { parseNvrams } from "./config/toml/nvrams.js"; +import { parseOptionalPrt } from "./config/toml/prt.js"; import { assertKnownKeys, mergeTomlTables } from "./config/toml/tables.js"; import { parseString } from "./config/toml/values.js"; import { parseOptionalWithdrawal } from "./config/toml/accounts.js"; @@ -37,6 +38,7 @@ const CONFIG_KEYS: readonly string[] = [ "drives", "machine", "nvrams", + "prt", "sdk", "withdrawal", ]; @@ -63,6 +65,7 @@ export const parse = (str: string[]): Config => { drives, machine: parseMachine(toml.machine), nvrams, + prt: parseOptionalPrt(toml.prt), sdk: parseString( toml.sdk, `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, diff --git a/apps/cli/src/config/defaults.ts b/apps/cli/src/config/defaults.ts index 67dd95b7..d227ceac 100644 --- a/apps/cli/src/config/defaults.ts +++ b/apps/cli/src/config/defaults.ts @@ -33,6 +33,7 @@ export const defaultConfig = (): Config => ({ drives: { root: defaultRootDriveConfig() }, machine: defaultMachineConfig(), nvrams: {}, + prt: undefined, sdk: `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, withdrawal: undefined, }); diff --git a/apps/cli/src/config/errors.ts b/apps/cli/src/config/errors.ts index 46ae0346..bdaba5d7 100644 --- a/apps/cli/src/config/errors.ts +++ b/apps/cli/src/config/errors.ts @@ -144,6 +144,16 @@ export class InvalidWithdrawalConfigError extends Error { } } +export class InvalidPrtConfigError extends Error { + /** + * @param where the table at fault, `[prt]` by default, or an entry such as `prt.sentries[2]` + */ + constructor(reason: string, where = "[prt]") { + super(`Invalid ${where}: ${reason}`); + this.name = "InvalidPrtConfigError"; + } +} + export class UnknownConfigKeyError extends Error { /** * @param where where the keys are, e.g. "in [machine]" or "at the top level" diff --git a/apps/cli/src/config/toml/prt.ts b/apps/cli/src/config/toml/prt.ts new file mode 100644 index 00000000..1b44cb88 --- /dev/null +++ b/apps/cli/src/config/toml/prt.ts @@ -0,0 +1,159 @@ +import type { TomlValue } from "smol-toml"; +import { isAddressEqual, zeroAddress } from "viem"; +import { + formatValue, + InvalidPrtConfigError, + InvalidTableError, +} from "../errors.js"; +import type { PrtOptions, PrtSentry, SentryType } from "../types.js"; +import { assertKnownKeys, isTomlTable } from "./tables.js"; +import { parseRequiredAddress } from "./values.js"; + +export const PRT_KEYS: readonly string[] = ["sentries", "sentry_manager"]; + +export const PRT_SENTRY_KEYS: readonly string[] = ["address", "label", "type"]; + +export const SENTRY_TYPES: readonly SentryType[] = ["sling"]; + +/** + * A label names a container, so it keeps to what a service name can hold. + */ +const LABEL_PATTERN = /^[a-z0-9_-]+$/; + +/** Where an entry is, in the 1-based order the node appoints the sentries. */ +export const sentryEntry = (index: number) => `prt.sentries[${index + 1}]`; + +/** + * The label of an entry: its own, or its 1-based position, which is also the sentry's ID on + * the consensus. + */ +export const sentryLabel = (sentry: PrtSentry, index: number): string => + sentry.label ?? (index + 1).toString(); + +const parseSentryType = (value: TomlValue, where: string): SentryType => { + if ( + typeof value === "string" && + SENTRY_TYPES.includes(value as SentryType) + ) { + return value as SentryType; + } + throw new InvalidPrtConfigError( + `type ${formatValue(value)} isn't supported, expected one of: ${SENTRY_TYPES.join(", ")}`, + where, + ); +}; + +const parseLabel = (value: TomlValue, where: string): string => { + if (typeof value === "string" && LABEL_PATTERN.test(value)) { + return value; + } + throw new InvalidPrtConfigError( + `label ${formatValue(value)} must be lowercase letters, digits, - or _`, + where, + ); +}; + +const parseSentry = (value: TomlValue, index: number): PrtSentry => { + const where = sentryEntry(index); + if (!isTomlTable(value)) { + throw new InvalidTableError(where, value); + } + assertKnownKeys(value, PRT_SENTRY_KEYS, `in ${where}`); + + const address = + value.address === undefined + ? undefined + : parseRequiredAddress(value.address, "address"); + if (address !== undefined && isAddressEqual(address, zeroAddress)) { + throw new InvalidPrtConfigError( + "address must not be the zero address", + where, + ); + } + const type = + value.type === undefined + ? undefined + : parseSentryType(value.type, where); + const label = + value.label === undefined ? undefined : parseLabel(value.label, where); + + if (type === undefined && address === undefined) { + throw new InvalidPrtConfigError( + "set a type for the CLI to run the sentry, or an address to appoint one run elsewhere", + where, + ); + } + + return { + ...(address !== undefined && { address }), + ...(type !== undefined && { type }), + ...(label !== undefined && { label }), + }; +}; + +const parseSentries = (value: TomlValue): PrtSentry[] => { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + throw new InvalidPrtConfigError( + `sentries must be [[prt.sentries]] entries, got ${formatValue(value)}`, + ); + } + const sentries = value.map(parseSentry); + + // the node rejects a duplicate, and so does a container name + sentries.forEach((sentry, index) => { + const label = sentryLabel(sentry, index); + const sameLabel = sentries.findIndex( + (other, i) => i < index && sentryLabel(other, i) === label, + ); + if (sameLabel >= 0) { + throw new InvalidPrtConfigError( + `label "${label}" is also the label of ${sentryEntry(sameLabel)}`, + sentryEntry(index), + ); + } + if (sentry.address === undefined) { + return; + } + const sameAddress = sentries.findIndex( + (other, i) => + i < index && + other.address !== undefined && + isAddressEqual(other.address, sentry.address as `0x${string}`), + ); + if (sameAddress >= 0) { + throw new InvalidPrtConfigError( + `address ${sentry.address} is also the address of ${sentryEntry(sameAddress)}`, + sentryEntry(index), + ); + } + }); + + return sentries; +}; + +/** + * Parses [prt], which `cartesi run --prt` uses to deploy the application's consensus with + * sentries. Like the other tables, it reads the keys it uses and rejects any other. + */ +export const parseOptionalPrt = (prt: TomlValue): PrtOptions | undefined => { + if (prt === undefined) { + return undefined; + } + if (!isTomlTable(prt)) { + throw new InvalidTableError("[prt]", prt); + } + assertKnownKeys(prt, PRT_KEYS, "in [prt]"); + + return { + ...(prt.sentry_manager !== undefined && { + sentryManager: parseRequiredAddress( + prt.sentry_manager, + "sentry_manager", + ), + }), + sentries: parseSentries(prt.sentries), + }; +}; diff --git a/apps/cli/src/config/types.ts b/apps/cli/src/config/types.ts index 92941f04..cf63b1da 100644 --- a/apps/cli/src/config/types.ts +++ b/apps/cli/src/config/types.ts @@ -124,10 +124,36 @@ export type WithdrawalOptions = { account_size?: number; // in bytes, default is 32 }; +/** + * The node the CLI runs for a PRT sentry. Only the Dave sling node exists today; a type is + * added once a released node can run as a sentry. + */ +export type SentryType = "sling"; + +/** + * One [[prt.sentries]] entry: a sentry appointed on the application's DaveConsensus, in entry + * order. With a `type` the CLI runs a node for it; without one it is appointed only. + */ +export type PrtSentry = { + address?: Address; // required without a type; with one, the CLI's sentry pool by default + type?: SentryType; + label?: string; // names the container, default is the 1-based position +}; + +/** + * The optional [prt] section, used by `cartesi run --prt` to deploy the application's + * DaveConsensus with sentries. + */ +export type PrtOptions = { + sentryManager?: Address; // may rotate sentries, default is none + sentries: PrtSentry[]; +}; + export type Config = { drives: Record; machine: MachineConfig; nvrams: Record; + prt?: PrtOptions; sdk: string; withdrawal?: WithdrawalOptions; }; diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index f1709fb3..63867ec6 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -267,6 +267,74 @@ export const getLastAcceptedEpoch = async (options: { return parseLastAcceptedEpoch(stdout); }; +/** + * The sentries of an application's DaveConsensus, as `cartesi-rollups-cli + * contract consensus` reports them. + */ +export type DaveConsensusSentries = { + address: Address; + claimStagingPeriod: number; + sentryManager: Address; + sentries: { id: number; address: Address }[]; + /** set once a tournament result is staged */ + allSentriesAgree?: boolean; +}; + +type CliDaveConsensus = { + type: string; + address: string; + claim_staging_period: number; + sentry_manager: string; + num_sentries: number; + sentries: { id: number; address: string }[]; + all_sentries_agree?: boolean; +}; + +/** + * Parse the JSON `cartesi-rollups-cli contract consensus --json` writes to + * stdout for a DaveConsensus + * @throws if the consensus is of another type + */ +export const parseDaveConsensus = (stdout: string): DaveConsensusSentries => { + const consensus = JSON.parse(stdout) as CliDaveConsensus; + if (consensus.type !== "DaveConsensus") { + throw new Error( + `The consensus at ${consensus.address} is a ${consensus.type}, not a DaveConsensus`, + ); + } + return { + address: getAddress(consensus.address), + claimStagingPeriod: consensus.claim_staging_period, + sentryManager: getAddress(consensus.sentry_manager), + sentries: consensus.sentries.map(({ id, address }) => ({ + id, + address: getAddress(address), + })), + allSentriesAgree: consensus.all_sentries_agree, + }; +}; + +/** + * Read the sentries of an application's DaveConsensus from the chain + */ +export const getDaveConsensus = async (options: { + application: Address; + projectName: string; +}): Promise => { + const { application, projectName } = options; + const stdout = await execNodeCommand({ + command: [ + "cartesi-rollups-cli", + "contract", + "consensus", + application, + "--json", + ], + projectName, + }); + return parseDaveConsensus(stdout); +}; + export const getApplicationDeployment = async ( options: ComposeParams, ): Promise => { @@ -689,6 +757,10 @@ export type DeployApplicationOptions = { projectName: string; prt?: boolean; salt?: Hex; + /** PRT only: the sentries appointed on the consensus, in ID order */ + sentries?: Address[]; + /** PRT only: the account that may rotate the sentries */ + sentryManager?: Address; snapshotPath: string; withdrawalConfig?: WithdrawalConfig; claimStagingPeriod: number; @@ -707,6 +779,8 @@ export const buildDeployApplicationArgs = ( name, prt, salt, + sentries, + sentryManager, snapshotPath, withdrawalConfig, claimStagingPeriod, @@ -730,6 +804,14 @@ export const buildDeployApplicationArgs = ( deployArgs.push("--prt"); } + // the node takes both only with --prt + if (sentryManager) { + deployArgs.push("--sentry-manager", sentryManager); + } + if (sentries && sentries.length > 0) { + deployArgs.push("--sentries", sentries.join(",")); + } + // the node takes this on the whole deploy command, PRT included deployArgs.push("--claim-staging-period", claimStagingPeriod.toString()); diff --git a/apps/cli/src/sentries.ts b/apps/cli/src/sentries.ts new file mode 100644 index 00000000..0e542aa3 --- /dev/null +++ b/apps/cli/src/sentries.ts @@ -0,0 +1,149 @@ +import chalk from "chalk"; +import type { Address } from "viem"; +import { DEVNET_MNEMONIC } from "./compose/node.js"; +import { + InvalidPrtConfigError, + type PrtOptions, + type SentryType, +} from "./config.js"; +import { sentryEntry, sentryLabel } from "./config/toml/prt.js"; +import { + DEVNET_ACCOUNTS, + findMnemonicAccountIndex, + getDevnetAccount, +} from "./wallet.js"; + +/** + * The sentry pool: devnet accounts a [[prt.sentries]] entry gets when it names none. They + * come after the accounts anvil funds (0 to 9), which the node, the CLI and the developer use, + * so a pool sentry shares nonces with nothing else. + */ +export const SENTRY_POOL_START = 20; +export const SENTRY_POOL_SIZE = 10; + +/** Where a sentry's address came from. */ +export type SentrySource = + | { kind: "pool"; index: number } // a devnet account of the sentry pool + | { kind: "devnet"; index: number } // another devnet account, named in the entry + | { kind: "external" }; // an address of the developer's own + +export type ResolvedSentry = { + entry: number; // 1-based position, the sentry's ID on the consensus + label: string; + address: Address; + type?: SentryType; + source: SentrySource; +}; + +export type ResolvedPrt = { + sentryManager?: Address; + sentries: ResolvedSentry[]; + warnings: string[]; +}; + +const isPoolIndex = (index: number) => + index >= SENTRY_POOL_START && index < SENTRY_POOL_START + SENTRY_POOL_SIZE; + +const sourceOf = (address: Address): SentrySource => { + const index = findMnemonicAccountIndex({ + address, + max: DEVNET_ACCOUNTS, + mnemonic: DEVNET_MNEMONIC, + }); + if (index === undefined) { + return { kind: "external" }; + } + return { kind: isPoolIndex(index) ? "pool" : "devnet", index }; +}; + +/** + * Gives every [[prt.sentries]] entry its address: the one it names, or else the next free + * account of the sentry pool, in entry order. Pool accounts named by an entry are skipped, so + * no two sentries share an address. Needs no chain, so it runs before anything starts. + * @throws InvalidPrtConfigError when the pool runs out + */ +export const resolveSentries = (prt: PrtOptions): ResolvedPrt => { + const warnings: string[] = []; + const named = new Set( + prt.sentries.flatMap(({ address }) => + address === undefined ? [] : [address.toLowerCase()], + ), + ); + let next = SENTRY_POOL_START; + + const sentries = prt.sentries.map((sentry, index): ResolvedSentry => { + const entry = index + 1; + const label = sentryLabel(sentry, index); + if (sentry.label !== undefined && sentry.type === undefined) { + warnings.push( + `${sentryEntry(index)} has a label but no type, so the CLI runs nothing for it and the label names nothing`, + ); + } + if (sentry.address !== undefined) { + return { + entry, + label, + address: sentry.address, + type: sentry.type, + source: sourceOf(sentry.address), + }; + } + while ( + isPoolIndex(next) && + named.has(getDevnetAccount(next).address.toLowerCase()) + ) { + next++; + } + if (!isPoolIndex(next)) { + throw new InvalidPrtConfigError( + `needs an address: the sentry pool, devnet accounts ${SENTRY_POOL_START} to ${SENTRY_POOL_START + SENTRY_POOL_SIZE - 1}, is used up`, + sentryEntry(index), + ); + } + const poolIndex = next++; + return { + entry, + label, + address: getDevnetAccount(poolIndex).address, + type: sentry.type, + source: { kind: "pool", index: poolIndex }, + }; + }); + + if (prt.sentryManager !== undefined && sentries.length === 0) { + warnings.push( + "[prt].sentry_manager is set but there are no [[prt.sentries]]; the manager has no sentries to rotate", + ); + } + + return { sentryManager: prt.sentryManager, sentries, warnings }; +}; + +/** + * The lines printed before a deploy with sentries: the manager, then one line per sentry + * with its full address so it can be copied into other tools. Nothing without sentries, + * where the manager has nothing to rotate. + */ +export const describePrt = (prt: ResolvedPrt): string[] => + prt.sentries.length === 0 + ? [] + : [ + prt.sentryManager === undefined + ? `sentry manager ${chalk.dim("none (the sentries can't be rotated)")}` + : `sentry manager ${prt.sentryManager}`, + ...prt.sentries.map(describeSentry), + ]; + +/** + * One line per sentry, with its full address so it can be copied into other tools. + */ +export const describeSentry = (sentry: ResolvedSentry): string => { + const { source } = sentry; + const origin = + source.kind === "external" + ? sentry.type === undefined + ? "appointed only" + : "your account" + : `devnet account ${source.index}`; + return `sentry ${chalk.cyan(sentry.label)} ${sentry.address} ${chalk.dim(`(${origin})`)}`; +}; diff --git a/apps/cli/src/wallet.ts b/apps/cli/src/wallet.ts index a92de599..6f4977b8 100644 --- a/apps/cli/src/wallet.ts +++ b/apps/cli/src/wallet.ts @@ -3,14 +3,17 @@ import { type Address, createTestClient, defineChain, + type Hex, http, isAddressEqual, publicActions, + toHex, walletActions, } from "viem"; import { mnemonicToAccount } from "viem/accounts"; import { anvil } from "viem/chains"; import { getProjectName } from "./base.js"; +import { DEVNET_MNEMONIC } from "./compose/node.js"; import { PREFERRED_PORT } from "./config.js"; import { getProjectPort } from "./exec/rollups.js"; @@ -79,6 +82,34 @@ export const findMnemonicAccountIndex = (options: { return undefined; }; +/** + * How many accounts the CLI derives from the devnet mnemonic: the 10 anvil + * funds and unlocks, and the ones after them, up to the sentry pool's last. + */ +export const DEVNET_ACCOUNTS = 30; + +/** + * Returns a devnet account by its index in the mnemonic, with the key the + * CLI signs with on its behalf. The mnemonic is public, so the key is too. + */ +export const getDevnetAccount = ( + index: number, +): { address: Address; privateKey: Hex } => { + if (!Number.isInteger(index) || index < 0 || index >= DEVNET_ACCOUNTS) { + throw new Error( + `There is no devnet account ${index}, the CLI derives accounts 0 to ${DEVNET_ACCOUNTS - 1}`, + ); + } + const account = mnemonicToAccount(DEVNET_MNEMONIC, { + addressIndex: index, + }); + const privateKey = account.getHdKey().privateKey; + if (!privateKey) { + throw new Error(`Devnet account ${index} has no private key`); + } + return { address: account.address, privateKey: toHex(privateKey) }; +}; + /** * Index of the devnet account that sends inputs and deposits when no `--from` * is given. The node signs its claims with account 0 and the CLI uses account diff --git a/apps/cli/tests/integration/commands/prt.test.ts b/apps/cli/tests/integration/commands/prt.test.ts new file mode 100644 index 00000000..302e1d48 --- /dev/null +++ b/apps/cli/tests/integration/commands/prt.test.ts @@ -0,0 +1,120 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import type { ResultPromise } from "execa"; +import fs from "fs-extra"; +import os from "node:os"; +import path from "node:path"; +import type { Address } from "viem"; +import { erc20PortalAddress, testUsdcAddress } from "../../../src/contracts"; +import { getDaveConsensus } from "../../../src/exec/rollups"; +import { TEST_SDK } from "../config"; +import { + buildApplication, + devnetAccount, + startApplication, + stopApplication, + TIMEOUT, +} from "../devnet"; + +/** + * Covers `cartesi run --prt` with sentries from [prt]: the application is + * deployed on a DaveConsensus with the sentries and the manager the config + * names, in order. + */ + +const EPOCH_LENGTH = 10; + +// long enough that acceptance within the test can only come from the sentries +const CLAIM_STAGING_PERIOD = 1_000_000; + +const fixture = path.join(__dirname, "fixtures", "accounts-withdrawal"); +const projectName = `prt-${process.pid}`; + +const sentryManager = devnetAccount(5); + +describe("prt", () => { + let appDir: string; + let cleanup: () => void; + let run: ResultPromise | undefined; + let application: Address; + let output: () => string; + + beforeAll(async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "prt-")); + cleanup = () => fs.removeSync(dir); + appDir = path.join(dir, "accounts-withdrawal"); + await fs.copy(fixture, appDir); + await fs.writeFile( + path.join(appDir, "cartesi.toml"), + `sdk = "${TEST_SDK}" + +[machine.env] +TRUSTED_ERC20_PORTAL = "${erc20PortalAddress}" +TRUSTED_ERC20_TOKEN = "${testUsdcAddress}" +ACCOUNTS_LABEL = "accounts" + +[drives.accounts] +builder = "empty" +format = "raw" +size = "4MB" +mount = false +user = "dapp" +accounts_drive = true + +[prt] +sentry_manager = "${sentryManager}" + +[[prt.sentries]] +type = "sling" + +[[prt.sentries]] +type = "sling" +`, + ); + + console.log("! Building the accounts-withdrawal application..."); + const build = await buildApplication(appDir); + expect(build.exitCode, build.all).toBe(0); + console.log("✓ Built the accounts-withdrawal application"); + + ({ application, output, run } = await startApplication({ + appDir, + epochLength: EPOCH_LENGTH, + extraArgs: [ + "--prt", + "--claim-staging-period", + CLAIM_STAGING_PERIOD.toString(), + ], + projectName, + })); + }, TIMEOUT); + + afterAll(() => stopApplication({ cleanup, projectName, run }), TIMEOUT); + + it( + "should appoint the sentries of [prt] on the consensus, in order", + async () => { + const consensus = await getDaveConsensus({ + application, + projectName, + }); + + expect(consensus.sentryManager).toBe(sentryManager); + expect(consensus.claimStagingPeriod).toBe(CLAIM_STAGING_PERIOD); + expect(consensus.sentries).toEqual([ + { id: 1, address: devnetAccount(20) }, + { id: 2, address: devnetAccount(21) }, + ]); + }, + TIMEOUT, + ); + + it("should print each sentry with its devnet account", () => { + const printed = output(); + + expect(printed).toContain(`sentry manager ${sentryManager}`); + expect(printed).toContain(`sentry 1 ${devnetAccount(20)}`); + expect(printed).toContain("(devnet account 20)"); + expect(printed).toContain(`sentry 2 ${devnetAccount(21)}`); + expect(printed).toContain("(devnet account 21)"); + }); +}); diff --git a/apps/cli/tests/integration/commands/run.test.ts b/apps/cli/tests/integration/commands/run.test.ts index ee2f76a0..c42b1d0a 100644 --- a/apps/cli/tests/integration/commands/run.test.ts +++ b/apps/cli/tests/integration/commands/run.test.ts @@ -104,6 +104,64 @@ describe("run", () => { }); }); + describe("[prt]", () => { + const sentries = ` +[prt] +sentry_manager = "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc" + +[[prt.sentries]] +type = "sling" + +[[prt.sentries]] +type = "sling" +`; + const config = (prt: string) => { + const file = path.join(dir, "prt.toml"); + fs.writeFileSync(file, prt); + return ["--config", file]; + }; + + // the section is read even without --prt, so a mistake fails early + it("should reject a duplicate sentry before rendering", async () => { + const { all, exitCode } = await dryRun([ + "--prt", + ...config(`${sentries} +[[prt.sentries]] +address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + +[[prt.sentries]] +address = "0x70997970c51812dc3a010c7d01b50e0d17dc79c8" +`), + ]); + + expect(exitCode).not.toBe(0); + expect(all).toContain("prt.sentries[4]"); + expect(all).toContain("is also the address of prt.sentries[3]"); + expect(all).not.toContain("services:"); + }); + + it("should ignore the section without --prt", async () => { + const { all, exitCode } = await dryRun(config(sentries)); + + expect(exitCode, all).toBe(0); + expect(all).toContain( + "[prt] in cartesi.toml is ignored without --prt", + ); + expect(all).toContain("services:"); + }); + + it("should render the configuration with --prt and sentries", async () => { + const { all, exitCode } = await dryRun([ + "--prt", + ...config(sentries), + ]); + + expect(exitCode, all).toBe(0); + expect(all).not.toContain("ignored without --prt"); + expect(all).toContain("services:"); + }); + }); + describe("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT", () => { it("should forward a supported endpoint to the node", async () => { const { all, exitCode } = await dryRun([], { diff --git a/apps/cli/tests/integration/devnet.ts b/apps/cli/tests/integration/devnet.ts index 96b94016..21483791 100644 --- a/apps/cli/tests/integration/devnet.ts +++ b/apps/cli/tests/integration/devnet.ts @@ -134,13 +134,20 @@ export const deposit = async ( /** * Starts the environment with `cartesi run` and waits for the application to * be deployed. + * @returns `output` reads what `cartesi run` has printed so far */ export const startApplication = async (options: { appDir: string; epochLength: number; + /** further `cartesi run` arguments, e.g. `--prt` */ + extraArgs?: string[]; projectName: string; -}): Promise<{ application: Address; run: ResultPromise }> => { - const { appDir, epochLength, projectName } = options; +}): Promise<{ + application: Address; + output: () => string; + run: ResultPromise; +}> => { + const { appDir, epochLength, extraArgs = [], projectName } = options; // without a terminal the environment runs in the foreground console.log(`! Starting the environment ${projectName}...`); @@ -157,6 +164,7 @@ export const startApplication = async (options: { epochLength.toString(), "--block-time", "1", + ...extraArgs, ], { all: true, @@ -165,6 +173,11 @@ export const startApplication = async (options: { }, ); + // the process keeps running, so its output is read as it comes + const chunks: string[] = []; + run.all?.on("data", (chunk) => chunks.push(String(chunk))); + const output = () => chunks.join(""); + const application = await waitFor(async () => { if (run.nodeChildProcess.exitCode !== null) { const { all } = await run; @@ -178,7 +191,7 @@ export const startApplication = async (options: { }, 600); console.log(`✓ Application deployed at ${application}`); - return { application, run }; + return { application, output, run }; }; /** diff --git a/apps/cli/tests/unit/config.test.ts b/apps/cli/tests/unit/config.test.ts index e9338ac4..7a599c86 100644 --- a/apps/cli/tests/unit/config.test.ts +++ b/apps/cli/tests/unit/config.test.ts @@ -16,6 +16,7 @@ import { InvalidEnvError, InvalidNumberValueError, InvalidNvramSizeError, + InvalidPrtConfigError, InvalidStringValueError, InvalidTableError, InvalidWithdrawalConfigError, @@ -155,6 +156,18 @@ describe("when parsing the fixtures", () => { user: undefined, }, }, + prt: { + sentryManager: "0x3333333333333333333333333333333333333333", + sentries: [ + { type: "sling" }, + { + address: "0x4444444444444444444444444444444444444444", + type: "sling", + label: "mine", + }, + { address: "0x5555555555555555555555555555555555555555" }, + ], + }, withdrawal: { guardian: "0x1111111111111111111111111111111111111111", withdrawal_output_builder: @@ -417,6 +430,185 @@ shared = true`, ); }); + /** + * [prt] + */ + describe("when parsing [prt]", () => { + const prt = (config: string) => parse([config]).prt; + const address = (digit: string) => + `0x${digit.repeat(40)}` as `0x${string}`; + + it("should parse the sentries in order, with only the keys set", () => { + const config = ` + [prt] + sentry_manager = "${address("3")}" + + [[prt.sentries]] + type = "sling" + + [[prt.sentries]] + type = "sling" + label = "mine" + address = "${address("4")}" + + [[prt.sentries]] + address = "${address("5")}" + `; + expect(prt(config)).toStrictEqual({ + sentryManager: address("3"), + sentries: [ + { type: "sling" }, + { address: address("4"), type: "sling", label: "mine" }, + { address: address("5") }, + ], + }); + }); + + it("should accept an empty section", () => { + expect(prt("[prt]")).toStrictEqual({ sentries: [] }); + }); + + it("should return undefined when [prt] is not defined", () => { + expect(parse([""]).prt).toBeUndefined(); + }); + + it("should fail when the section is not a table", () => { + expect(() => prt("prt = 42")).toThrowError( + new InvalidTableError("[prt]", 42), + ); + }); + + it("should fail when sentries is not an array of tables", () => { + expect(() => prt('[prt]\nsentries = "0x1234"')).toThrowError( + new InvalidPrtConfigError( + "sentries must be [[prt.sentries]] entries, got 0x1234", + ), + ); + expect(() => prt("[prt]\nsentries = [42]")).toThrowError( + new InvalidTableError("prt.sentries[1]", 42), + ); + }); + + it("should checksum the addresses", () => { + const config = ` + [[prt.sentries]] + address = "0x70997970c51812dc3a010c7d01b50e0d17dc79c8" + `; + expect(prt(config)?.sentries[0].address).toBe( + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + ); + }); + + it.each([ + ["sentry_manager", "[prt]"], + ["address", "[[prt.sentries]]"], + ])("should fail when %s is not a valid address", (key, table) => { + expect(() => + prt(`${table}\n${key} = "invalid_address"`), + ).toThrowError( + new InvalidAddressValueError("invalid_address", key), + ); + }); + + // the node refuses it, so the config does too + it("should fail for the zero address", () => { + expect(() => + prt(`[[prt.sentries]]\naddress = "${address("0")}"`), + ).toThrowError( + new InvalidPrtConfigError( + "address must not be the zero address", + "prt.sentries[1]", + ), + ); + }); + + it("should fail when two entries name the same address", () => { + const config = ` + [[prt.sentries]] + address = "${address("4")}" + + [[prt.sentries]] + type = "sling" + + [[prt.sentries]] + type = "sling" + address = "${address("4").toLowerCase()}" + `; + expect(() => prt(config)).toThrowError( + new InvalidPrtConfigError( + `address ${address("4")} is also the address of prt.sentries[1]`, + "prt.sentries[3]", + ), + ); + }); + + it("should fail for a type the CLI can't run", () => { + expect(() => + prt('[[prt.sentries]]\ntype = "rollups-node"'), + ).toThrowError( + new InvalidPrtConfigError( + "type rollups-node isn't supported, expected one of: sling", + "prt.sentries[1]", + ), + ); + }); + + it("should fail for an entry with neither type nor address", () => { + expect(() => prt('[[prt.sentries]]\nlabel = "idle"')).toThrowError( + new InvalidPrtConfigError( + "set a type for the CLI to run the sentry, or an address to appoint one run elsewhere", + "prt.sentries[1]", + ), + ); + }); + + it.each(['"My Sentry"', '"a/b"', '""', "7"])( + "should fail for the label %s", + (label) => { + expect(() => + prt(`[[prt.sentries]]\ntype = "sling"\nlabel = ${label}`), + ).toThrowError( + new InvalidPrtConfigError( + `label ${JSON.parse(label.startsWith('"') ? label : `"${label}"`) === "" ? "" : label.replace(/"/g, "")} must be lowercase letters, digits, - or _`, + "prt.sentries[1]", + ), + ); + }, + ); + + // a label names a container, and so does the default of another entry + it("should fail when a label repeats another entry's default label", () => { + const config = ` + [[prt.sentries]] + type = "sling" + label = "2" + + [[prt.sentries]] + type = "sling" + `; + expect(() => prt(config)).toThrowError( + new InvalidPrtConfigError( + 'label "2" is also the label of prt.sentries[1]', + "prt.sentries[2]", + ), + ); + }); + + it("should fail for an unknown key in [prt]", () => { + expect(() => prt("[prt]\nmanager = 1")).toThrowError( + "Unknown key 'manager' in [prt], expected one of: sentries, sentry_manager", + ); + }); + + it("should fail for an unknown key in an entry", () => { + expect(() => + prt('[[prt.sentries]]\ntype = "sling"\nkey = "x"'), + ).toThrowError( + "Unknown key 'key' in prt.sentries[1], expected one of: address, label, type", + ); + }); + }); + /** * accounts drive validation, before the machine is built */ @@ -1069,7 +1261,7 @@ shared = true`, expect(() => parse(['[drive.root]\nbuilder = "docker"']), ).toThrowError( - "Unknown key 'drive' at the top level, expected one of: drives, machine, nvrams, sdk, withdrawal", + "Unknown key 'drive' at the top level, expected one of: drives, machine, nvrams, prt, sdk, withdrawal", ); }); diff --git a/apps/cli/tests/unit/config/fixtures/full.toml b/apps/cli/tests/unit/config/fixtures/full.toml index fabdcca9..e129a540 100644 --- a/apps/cli/tests/unit/config/fixtures/full.toml +++ b/apps/cli/tests/unit/config/fixtures/full.toml @@ -84,3 +84,17 @@ filename = "./seed.raw" # existing raw image with the initial contents guardian = "0x1111111111111111111111111111111111111111" # devnet account by default withdrawal_output_builder = "0x2222222222222222222222222222222222222222" # devnet builder by default, required on a fork account_size = 64 # bytes per account, a power of two, 32 by default + +[prt] # optional, read by `cartesi run --prt`: the sentries of the application's consensus +sentry_manager = "0x3333333333333333333333333333333333333333" # may rotate sentries, default is none + +[[prt.sentries]] # appointed in this order, with IDs from 1 +type = "sling" # the CLI runs a Dave sling node for it; without an address it gets devnet account 20 + +[[prt.sentries]] +type = "sling" +label = "mine" # names the container sentry-mine, default is the position +address = "0x4444444444444444444444444444444444444444" # an account of your own + +[[prt.sentries]] +address = "0x5555555555555555555555555555555555555555" # no type: appointed only, run elsewhere diff --git a/apps/cli/tests/unit/config/fixtures/prt/config.toml b/apps/cli/tests/unit/config/fixtures/prt/config.toml new file mode 100644 index 00000000..85901588 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/prt/config.toml @@ -0,0 +1,20 @@ +# example of an application deployed with PRT sentries by `cartesi run --prt`. +# without --prt the section is ignored. + +[prt] +# optional, may rotate sentries on the consensus. default is none +# sentry_manager = "0x3333333333333333333333333333333333333333" + +# appointed on the consensus in this order, with IDs from 1. an entry with a +# type is run by the CLI, as container sentry-