From 7ee88915e175016506ea8fb4e52b3da89ace1310 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 20 Oct 2026 17:25:21 +0100 Subject: [PATCH 01/15] wip(cli): temporary plumbing for the rollups-node alpha.14 build, drop before merge --- .github/workflows/cli.yaml | 29 +++++++++ .github/workflows/sdk.yaml | 11 ++-- packages/sdk/.gitignore | 1 + packages/sdk/Dockerfile | 38 +++++------- packages/sdk/README.md | 9 +++ packages/sdk/devnet | 2 +- packages/sdk/docker-bake.hcl | 9 ++- packages/sdk/package.json | 3 +- packages/sdk/scripts/fetch-rollups-node.sh | 70 ++++++++++++++++++++++ 9 files changed, 138 insertions(+), 34 deletions(-) create mode 100755 packages/sdk/scripts/fetch-rollups-node.sh diff --git a/.github/workflows/cli.yaml b/.github/workflows/cli.yaml index f84381229..caf6b7acc 100644 --- a/.github/workflows/cli.yaml +++ b/.github/workflows/cli.yaml @@ -3,7 +3,9 @@ on: pull_request: paths: - ".github/workflows/cli.yaml" + - ".github/workflows/sdk.yaml" - "apps/cli/**" + - "packages/sdk/**" - "packages/tsconfig/**" concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} @@ -13,11 +15,21 @@ permissions: packages: write pull-requests: write jobs: + sdk: + uses: ./.github/workflows/sdk.yaml + secrets: inherit + permissions: + contents: read + packages: write + id-token: write + actions: write build: + needs: sdk runs-on: ubuntu-latest env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} + SDK_TAG: pr-${{ github.event.pull_request.number }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -28,6 +40,20 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Retag SDK images + run: | + for image in sdk rollups-runtime rollups-database; do + docker pull "ghcr.io/cartesi/$image:$SDK_TAG" + docker tag "ghcr.io/cartesi/$image:$SDK_TAG" "cartesi/$image:$SDK_TAG" + done + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -45,6 +71,9 @@ jobs: - name: Test run: bun test apps/cli/ + env: + CARTESI_TEST_SDK: cartesi/sdk:${{ env.SDK_TAG }} + CARTESI_TEST_RUNTIME_VERSION: ${{ env.SDK_TAG }} - name: Coverage Report uses: 70-10/bun-coverage-report-action@6173866ce2a31456a726ff3f4c91f230bd94a9e9 # v1.0.3 diff --git a/.github/workflows/sdk.yaml b/.github/workflows/sdk.yaml index e1da4d3fc..2e19b4433 100644 --- a/.github/workflows/sdk.yaml +++ b/.github/workflows/sdk.yaml @@ -6,12 +6,8 @@ on: required: true DOCKERHUB_TOKEN: required: true - pull_request: - paths: - - ".github/workflows/sdk.yaml" - - "packages/sdk/**" concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + group: sdk-${{ github.ref }} cancel-in-progress: true permissions: contents: read @@ -83,6 +79,11 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + # WIP: the run id is the script's default, the same one a local + # `bun run build` takes, so CI and reviewers build the same node + - name: Fetch rollups-node packages + run: ./packages/sdk/scripts/fetch-rollups-node.sh + - name: Build and push uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0 if: ${{ !startsWith(github.ref, 'refs/tags/sdk@') }} diff --git a/packages/sdk/.gitignore b/packages/sdk/.gitignore index 85b2aef5c..a8453c9c7 100644 --- a/packages/sdk/.gitignore +++ b/packages/sdk/.gitignore @@ -1 +1,2 @@ build.json +rollups-node/ diff --git a/packages/sdk/Dockerfile b/packages/sdk/Dockerfile index 72af7b362..b37f67a71 100644 --- a/packages/sdk/Dockerfile +++ b/packages/sdk/Dockerfile @@ -88,7 +88,7 @@ RUN < /opt/devnet/anvil_state.json rm /tmp/prt-anvil.tar.gz echo ${CARTESI_PRT_VERSION} > /opt/devnet/PRT_VERSION @@ -160,11 +160,11 @@ ARG CARTESI_PRT_VERSION ARG TARGETARCH WORKDIR /tmp RUN </docker (not .../data), +# so copy the whole VOLUME directory; the final stage shares the same default PGDATA +COPY --from=postgresql-initdb /var/lib/postgresql /var/lib/postgresql ################################################################################ # alto build @@ -314,21 +310,19 @@ EOF ################################################################################ # linux kernel image stage FROM scratch AS kernel-image -ARG CARTESI_IMAGE_KERNEL_VERSION ARG CARTESI_LINUX_KERNEL_VERSION -ADD --checksum=sha256:5c900060da2db2bfa84cd39cd9cd722988c83c42225f3cac55f2d3157e48f32f \ - https://github.com/cartesi/image-kernel/releases/download/v${CARTESI_IMAGE_KERNEL_VERSION}/linux-${CARTESI_LINUX_KERNEL_VERSION}.bin \ +ADD --checksum=sha256:ad12ad141e639fb7b8ce0477dfbb7aa7a4d178222b06e0ce9b335a1c5230f0e1 \ + https://github.com/cartesi/linux/releases/download/v${CARTESI_LINUX_KERNEL_VERSION}/linux-${CARTESI_LINUX_KERNEL_VERSION}.bin \ /usr/share/cartesi-machine/images/linux.bin ################################################################################ # linux headers stage FROM base AS kernel-headers -ARG CARTESI_IMAGE_KERNEL_VERSION ARG CARTESI_LINUX_KERNEL_VERSION -ADD --checksum=sha256:e3f140a8632fcfb18218e7aea32a7d002124d3dce5ba8121ae061ab7f40a6cf4 \ - https://github.com/cartesi/image-kernel/releases/download/v${CARTESI_IMAGE_KERNEL_VERSION}/linux-headers-${CARTESI_LINUX_KERNEL_VERSION}.tar.xz \ +ADD --checksum=sha256:0c6ef54c47e50502a00f6d3785be1f797f74c8bc2f0b3bc0f22bc26017569aa1 \ + https://github.com/cartesi/linux/releases/download/v${CARTESI_LINUX_KERNEL_VERSION}/linux-headers-${CARTESI_LINUX_KERNEL_VERSION}.tar.xz \ /tmp/linux-headers-${CARTESI_LINUX_KERNEL_VERSION}.tar.xz RUN tar -xJf "/tmp/linux-headers-${CARTESI_LINUX_KERNEL_VERSION}.tar.xz" -C / diff --git a/packages/sdk/README.md b/packages/sdk/README.md index c606639e4..82a0c703e 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -16,6 +16,15 @@ docker run cartesi/sdk: cartesi-machine --help ## Building the image +> **WIP, until the next rollups-node release:** the images install a rollups-node built by +> rollups-node CI instead of a release. `bun run build` (here or at the repository root) +> first runs `scripts/fetch-rollups-node.sh`, which downloads the `.deb` files of the +> pinned run into `rollups-node/` (git-ignored) and skips the download once they are +> there, then bakes `cartesi/sdk:devel`, `cartesi/rollups-runtime:devel` and +> `cartesi/rollups-database:devel`. Use them with `sdk = "cartesi/sdk:devel"` in +> `cartesi.toml` and `cartesi run --runtime-version devel`. Set `ROLLUPS_NODE_RUN_ID` +> to take another run. + To build and publish the images to the registry, you need to have the `docker` CLI installed and logged in to both `ghcr.io/cartesi` and `docker.io` registries. Then you can run the following command: diff --git a/packages/sdk/devnet b/packages/sdk/devnet index 960f2d6c4..418adf174 100755 --- a/packages/sdk/devnet +++ b/packages/sdk/devnet @@ -1,3 +1,3 @@ #!/usr/bin/env sh set -e -exec anvil --load-state /usr/share/cartesi/anvil_state.json "$@" +exec anvil --load-state /usr/share/cartesi/anvil_state.json --preserve-historical-states --max-persisted-states 86400 "$@" diff --git a/packages/sdk/docker-bake.hcl b/packages/sdk/docker-bake.hcl index d5bcaa8ad..feb3343b1 100644 --- a/packages/sdk/docker-bake.hcl +++ b/packages/sdk/docker-bake.hcl @@ -9,18 +9,17 @@ target "default" { ALTO_VERSION = "1.2.7" ALTO_PACKAGE_VERSION = "0.0.20" CARTESI_BASE_IMAGE = "docker.io/library/debian:trixie-20260918@sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c" - CARTESI_IMAGE_KERNEL_VERSION = "0.21.0" - CARTESI_LINUX_KERNEL_VERSION = "6.5.13-ctsi-2-v0.21.0" - CARTESI_MACHINE_EMULATOR_VERSION = "0.21.0" + CARTESI_LINUX_KERNEL_VERSION = "6.18.56-ctsi-1" + CARTESI_MACHINE_EMULATOR_VERSION = "0.22.0" CARTESI_PASSKEY_SERVER_VERSION = "1.0.1" CARTESI_PAYMASTER_VERSION = "0.2.0" - CARTESI_PRT_VERSION = "3.0.0-alpha.5" + CARTESI_PRT_VERSION = "3.0.0-alpha.6" CARTESI_ROLLUPS_NODE_VERSION = "2.0.0-alpha.13" FOUNDRY_VERSION = "1.5.1" NITRO_VERSION = "c937fa4fd202074dd250086ebd92de6884968b84" # v0.8.1 NODE_VERSION = "24.14.0" NVM_VERSION = "977563e97ddc66facf3a8e31c6cff01d236f09bd" # 0.40.3 - POSTGRES_BASE_IMAGE = "docker.io/library/postgres:17-trixie@sha256:d74eeac9a635390a49bc21bd49fccd973de707e2a53a76ac49b552b8712ec46f" + POSTGRES_BASE_IMAGE = "docker.io/library/postgres:18-trixie@sha256:74935e72241653ca55e0414067e6d8763aceb8a810eb51b452253ec3dcfc4336" SQUASHFS_TOOLS_VERSION = "bad1d213ab6df587d6fa0ef7286180fbf7b86167" # 4.7.4 SU_EXEC_VERSION = "0.3" TELEGRAF_VERSION = "1.38.0" diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 464fc4cfb..6adfe1de5 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -3,6 +3,7 @@ "private": true, "version": "0.12.0-alpha.44", "scripts": { - "build": "docker buildx bake --load --metadata-file=build.json" + "build": "./scripts/fetch-rollups-node.sh && docker buildx bake --load --metadata-file=build.json", + "fetch:rollups-node": "./scripts/fetch-rollups-node.sh" } } diff --git a/packages/sdk/scripts/fetch-rollups-node.sh b/packages/sdk/scripts/fetch-rollups-node.sh new file mode 100755 index 000000000..5aa6adccc --- /dev/null +++ b/packages/sdk/scripts/fetch-rollups-node.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Fetch the rollups-node .deb files built by cartesi/rollups-node CI, through +# nightly.link. WIP plumbing until the next rollups-node release. +# +# By default this takes the build the branch is verified against: +# rollups-node#815 (chore/bump-version-alpha14) at e9eebb72, 2.0.0-alpha.14. +# ROLLUPS_NODE_RUN_ID picks another run; ROLLUPS_NODE_RUN_ID=latest takes the +# latest successful run of ROLLUPS_NODE_BRANCH (default next/2.0). +# +# The download is skipped when the .deb files of the same run are already in +# place with the checksums recorded in SOURCE, so `bun run build` can call this +# every time. +set -euo pipefail + +DEFAULT_RUN_ID="41211121596" +BRANCH="${ROLLUPS_NODE_BRANCH:-next/2.0}" +RUN_ID="${ROLLUPS_NODE_RUN_ID:-$DEFAULT_RUN_ID}" +BASE="https://nightly.link/cartesi/rollups-node" +DEST="$(cd "$(dirname "$0")/.." && pwd)/rollups-node" + +sha256() { + if command -v sha256sum >/dev/null; then sha256sum "$@"; else shasum -a 256 "$@"; fi +} + +if [[ "$RUN_ID" == "latest" ]]; then + page="$BASE/workflows/build/${BRANCH//\//%2F}" + RUN_ID="$(curl -fsSL "$page" | grep -oE 'actions/runs/[0-9]+/artifacts\.zip' | head -n1 | grep -oE '[0-9]+' || true)" + [[ -n "$RUN_ID" ]] || { echo "no successful run found at $page" >&2; exit 1; } +fi + +run_url="https://github.com/cartesi/rollups-node/actions/runs/$RUN_ID" + +# already fetched: SOURCE names the same run, and each .deb still has the +# checksum recorded there (compared here: macOS's sha256sum has no --check) +fetched() { + [[ -f "$DEST/SOURCE" ]] && grep -qx "run: $run_url" "$DEST/SOURCE" || return 1 + for arch in amd64 arm64; do + local file="cartesi-rollups-node_$arch.deb" expected actual + [[ -f "$DEST/$file" ]] || return 1 + expected="$(grep " $file\$" "$DEST/SOURCE" | cut -d' ' -f1)" + actual="$(cd "$DEST" && sha256 "$file" | cut -d' ' -f1)" + [[ -n "$expected" && "$expected" == "$actual" ]] || return 1 + done +} + +if fetched; then + echo "rollups-node packages of run $RUN_ID already in $DEST" + exit 0 +fi + +url="$BASE/actions/runs/$RUN_ID/artifacts.zip" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +echo "fetching rollups-node packages of run $RUN_ID..." +curl -fsSL "$url" -o "$tmp/artifacts.zip" +unzip -q "$tmp/artifacts.zip" -d "$tmp/artifacts" + +mkdir -p "$DEST" +rm -f "$DEST"/*.deb +for arch in amd64 arm64; do + src=("$tmp"/artifacts/cartesi-rollups-node-v*_"$arch".deb) + [[ -f "${src[0]}" ]] || { echo "missing $arch .deb in run $RUN_ID" >&2; exit 1; } + cp "${src[0]}" "$DEST/cartesi-rollups-node_$arch.deb" +done + +{ + echo "run: $run_url" + echo "url: $url" + (cd "$DEST" && sha256 cartesi-rollups-node_*.deb) +} | tee "$DEST/SOURCE" From 40c168247cf4a790dec484335a8232761c908080 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 20 Oct 2026 18:41:36 +0100 Subject: [PATCH 02/15] 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 000000000..3afe73494 --- /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 30571500e..1fb601123 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 eb0c10ccb..2ef48f520 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 9f0c6ce7c04d369eef15920e85aa02094e468d49 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 20 Oct 2026 18:41:36 +0100 Subject: [PATCH 03/15] 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 4b39f6917..e05b8581e 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 350e75296..02ed278c3 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 d5f16b519742141570ea447959eefd65180df364 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Wed, 28 Oct 2026 20:42:39 +0000 Subject: [PATCH 04/15] 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 fd7c9b5ba..aaa2e5f64 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 2ffb61588..9c1b634ff 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 1aa3af99b437b8d38748b70331b7b2bc8dabcc05 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Wed, 4 Nov 2026 09:33:53 +0000 Subject: [PATCH 05/15] 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 000000000..720d12766 --- /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 fb9d1d382..750c6c9e4 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 98f12444d..bb3025ba9 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 8ba9a706cf30f67e007f7a2fd9dd60b96fb90bae Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 08:28:40 +0000 Subject: [PATCH 06/15] 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 61219056e..00f8ddcd4 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 000000000..fa42e2413 --- /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 23268c4db..c92f0563b 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 b67becdcad1175fb83bf1408e4234ae40078629b Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 08:50:54 +0000 Subject: [PATCH 07/15] 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 000000000..3f15cf299 --- /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 4c6e45c33..9d843b6ee 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 fa42e2413..6c4a08f34 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 b80a83542..05d2d72af 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 65490356322a1d3d9df8b9afdb54f70c58b52483 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 09:09:35 +0000 Subject: [PATCH 08/15] 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 000000000..296971672 --- /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 7afa2e869..09d00030a 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 9d843b6ee..0a6716867 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 00f8ddcd4..862b2d35f 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 000000000..46055685d --- /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 000000000..7ec6c9ee0 --- /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 157badacf6fea911ea047108841c79698a9b525c Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 09:38:11 +0000 Subject: [PATCH 09/15] 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 000000000..697930802 --- /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 c1607feaa..696884808 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 fe8780c20..0aef4f4b2 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 0a6716867..5d4c4bde7 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 46055685d..aa2e851be 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 000000000..55e704c44 --- /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 05d2d72af..f4fc035ce 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 b775b737a9c96f33c88a92a43b678609aec5527b Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 10:47:48 +0000 Subject: [PATCH 10/15] 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 000000000..b341b37b2 --- /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 5d4c4bde7..00d1e9f89 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 000000000..ad1003cc8 --- /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 862b2d35f..10f1a54dc 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 83770d9a6..de1f3c301 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 aa2e851be..bf6c5dc9d 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 57b0dde17..e82781d56 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 f4fc035ce..95e05a503 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 f75188e20..bf1f584af 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 f60f7b7b72356b35d4fa29e6f155513043ac393e Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 11:55:41 +0000 Subject: [PATCH 11/15] 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 000000000..a76fe6d55 --- /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 10f1a54dc..c6bf44f62 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 de1f3c301..1fab8cca1 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 bf6c5dc9d..ee2f76a02 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 bf1f584af..6bb6f20da 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 ca0174c7e7fc529878f1560bf7ec9d5331c79b94 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 12:30:33 +0000 Subject: [PATCH 12/15] 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 000000000..11b2a21fe --- /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 64d31e1af..e502960d9 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 a57287bb7..b40ba3665 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 70b775ed5..8ba8f3e10 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 896c51e75..4aaaf7e0e 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 2b0bf6c76..ad23054d4 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 62e21ff90..debb653e8 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 595df6b4e..a92de599d 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 000000000..c2a1c3611 --- /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 22a03f721..aa3a1573d 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 b6cb838ab817a127c463c0ab10e96173eca08c3a Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Thu, 5 Nov 2026 20:24:35 +0000 Subject: [PATCH 13/15] 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 000000000..33a29a4de --- /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 09d00030a..82909eb6b 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 ad0795837..4411a6831 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 67dd95b78..d227ceace 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 46ae03465..bdaba5d71 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 000000000..1b44cb88d --- /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 92941f049..cf63b1da0 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 c6bf44f62..f475013a1 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, @@ -728,6 +802,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 000000000..0e542aa35 --- /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 a92de599d..6f4977b83 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 000000000..302e1d48a --- /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 ee2f76a02..c42b1d0ac 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 96b940167..214837910 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 e9338ac43..7a599c864 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 fabdcca9f..e129a540a 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 000000000..859015889 --- /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-