diff --git a/.github/workflows/release-testnode-image.yml b/.github/workflows/release-testnode-image.yml index 35642a8..6725ee9 100644 --- a/.github/workflows/release-testnode-image.yml +++ b/.github/workflows/release-testnode-image.yml @@ -1,7 +1,7 @@ name: Publish Testnode # On a version tag (or manual dispatch), publish a testnode image for every -# variant x nitro-contracts-version we support. Each image's chain state is +# current supported variant. Each image's chain state is # GENERATED inline in CI by running `init` (no pre-built snapshot release # bundles, nothing to publish out of band). The intended (variant x version) # matrix and the init flags per combo come from runtime.mjs resolvePublishMatrix. @@ -30,15 +30,6 @@ on: - l3-custom-16 - l3-custom-18 - l3-custom-20 - nitro-contracts-version: - description: "Nitro contracts version (or all)" - required: true - default: "all" - type: choice - options: - - all - - v2.1 - - v3.2 dockerhub-repository: description: "Docker Hub repository to publish to (retarget for a dry run)" required: false @@ -88,15 +79,12 @@ jobs: - name: Build publish matrix id: matrix env: - # On tag push: every variant x every supported version. On dispatch: - # the chosen filters. resolvePublishMatrix only emits supported combos - # (e.g. no v2.1 for L2-only variants). + # New releases are built from the current Nitro contracts family. VARIANT_FILTER: ${{ github.ref_type == 'tag' && 'all' || (github.event_name == 'workflow_dispatch' && inputs.variant || 'all') }} - VERSION_FILTER: ${{ github.ref_type == 'tag' && 'all' || (github.event_name == 'workflow_dispatch' && inputs.nitro-contracts-version || 'all') }} run: >- node --input-type=module -e "import { resolvePublishMatrix } from './packages/testnode/src/runtime.mjs'; - const include = resolvePublishMatrix(process.env.VARIANT_FILTER, process.env.VERSION_FILTER); + const include = resolvePublishMatrix(process.env.VARIANT_FILTER, 'v3.2'); if (!include.length) throw new Error('empty publish matrix'); console.error('publish matrix:', JSON.stringify(include)); const { appendFileSync } = await import('node:fs'); @@ -121,6 +109,8 @@ jobs: # pin the location and the commit the contract-deployer Dockerfiles use. TOKEN_BRIDGE_LOCAL_DIR: ${{ github.workspace }}/../token-bridge-contracts TOKEN_BRIDGE_COMMIT: 5975d8f7360816341be7f94fd333ef240f4aec23 + NITRO_CONTRACTS_LOCAL_DIR: ${{ github.workspace }}/../nitro-contracts + NITRO_CONTRACTS_COMMIT: 2695e7b3e3f460531e2b77fed48a60561c54d90e steps: - name: Checkout uses: actions/checkout@v4 @@ -156,6 +146,19 @@ jobs: test -f node_modules/ts-node/dist/bin.js test -f scripts/deployment/deployTokenBridgeCreator.ts + - name: Prepare Nitro contracts checkout + run: | + set -euxo pipefail + mkdir -p "$NITRO_CONTRACTS_LOCAL_DIR" + cd "$NITRO_CONTRACTS_LOCAL_DIR" + git init . + git remote add origin https://github.com/OffchainLabs/nitro-contracts.git + git fetch --depth 1 origin "$NITRO_CONTRACTS_COMMIT" + git checkout --detach FETCH_HEAD + git submodule update --init --recursive --depth 1 + test "$(node -p 'require("./package.json").name')" = "@arbitrum/nitro-contracts" + test "$(node -p 'require("./package.json").version')" = "3.2.0" + # Generate this combo's snapshot inline via init. init is flaky in CI # (intermittently exits 0 at wait-l1 before capturing, ~50%), so success # is driven off the manifest existing, with up to 3 attempts and a reset @@ -164,7 +167,6 @@ jobs: - name: Generate snapshot (init, retry until captured) env: SNAPSHOT_ID: ${{ matrix.snapshotId }} - NC_VERSION: ${{ matrix.contractsVersion }} DECIMALS: ${{ matrix.feeTokenDecimals }} TIMEBOOST: ${{ matrix.timeboostEnabled }} run: | @@ -229,7 +231,6 @@ jobs: # `docker compose ... down -v` below so a timed-out attempt recovers. set +e timeout 1500 pnpm dev init \ - --nitro-contracts-version "$NC_VERSION" \ $EXTRA \ --capture-id "$SNAPSHOT_ID" \ --rebuild \ @@ -262,7 +263,6 @@ jobs: node scripts/ci/prepare-testnode-context.mjs --variant "${{ matrix.variant }}" --snapshot-id "${{ matrix.snapshotId }}" - --nitro-contracts-version "${{ matrix.contractsVersion }}" # Both refs are derived from one suffix so the registries cannot disagree # about what a version means. The suffix shape must match diff --git a/README.md b/README.md index 3e7a9e8..b536c72 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,21 @@ pnpm dev bake \ --push # optional; docker login is your responsibility ``` +New builds use the stable Nitro contracts v3.2.0 release by default. For local +development, point `NITRO_CONTRACTS_LOCAL_DIR` at a Nitro 3.x checkout; a sibling +`../nitro-contracts` checkout is detected automatically: + +```bash +NITRO_CONTRACTS_LOCAL_DIR=../nitro-contracts pnpm dev init --rebuild +NITRO_CONTRACTS_LOCAL_DIR=../nitro-contracts pnpm dev bake --rebuild \ + --setup-command "./scripts/deploy-and-seed.sh" \ + --image-ref ghcr.io/acme/arbitrum-testnode:governance +``` + +The checkout determines the contracts family; `init` and `bake` no longer expose +separate Nitro version or branch selectors. New builds require Nitro 3.x. Existing +published v2.1 images remain available through `start` and the run action. + To bake straight from an existing snapshot (no setup step), use the à-la-carte subcommand: @@ -285,7 +300,8 @@ job — log in before invoking it: ``` By default the action installs a base snapshot release (via `github-token`) and -restores it; set `rebuild: true` to run a full init instead. +restores it; set `rebuild: true` to run a full init instead. Rebuilds also accept +`nitro-contracts-ref` (default `v3.2.0`) and `fee-token-decimals`. ### Booting a custom image @@ -386,11 +402,11 @@ Publish one variant image from GitHub Actions: workflow: Publish Testnode version: v0.2.3 variant: l3-eth -nitro-contracts-version: v3.2 snapshot-version: v0.1.6 ``` -Publish every catalog entry by setting `variant` to `all`. Publish every supported Nitro contracts tag by setting `nitro-contracts-version` to `all`. +Publish every current catalog entry by setting `variant` to `all`. Existing v2.1 +images remain resolvable but are not rebuilt by new releases. The default Timeboost publish target is `l2-timeboost`, which expects the `l2-timeboost` snapshot ID in the selected snapshot release. It can be published directly with `variant: l2-timeboost` or through the `name: timeboost` entry in `config/testnodes.json`. diff --git a/apps/cli/src/commands/bake.ts b/apps/cli/src/commands/bake.ts index 965fd54..c4e5776 100644 --- a/apps/cli/src/commands/bake.ts +++ b/apps/cli/src/commands/bake.ts @@ -52,10 +52,6 @@ const bakeOptions = z.object({ .string() .optional() .describe("Base snapshot to restore when not rebuilding (default: default)"), - nitroContractsVersion: z - .string() - .optional() - .describe("Nitro contracts version for a rebuild init (e.g. v2.1, v3.2)"), feeTokenDecimals: z .number() .optional() @@ -63,6 +59,18 @@ const bakeOptions = z.object({ timeboostEnabled: z.boolean().optional().describe("Enable Timeboost for a rebuild init"), }); +function rebuildInitOptions(options: z.infer) { + return { + rebuild: true, + ...(options.feeTokenDecimals !== undefined + ? { feeTokenDecimals: options.feeTokenDecimals } + : {}), + ...(options.timeboostEnabled !== undefined + ? { timeboostEnabled: options.timeboostEnabled } + : {}), + }; +} + /** * Boot the base stack the customization runs against. `--rebuild` runs a full * init; otherwise the installed base snapshot is restored and started, mirroring @@ -75,21 +83,7 @@ async function ensureBaseStack( options: z.infer, ): Promise { if (options.rebuild) { - await runInitCommand( - { - rebuild: true, - ...(options.nitroContractsVersion - ? { nitroContractsVersion: options.nitroContractsVersion } - : {}), - ...(options.feeTokenDecimals !== undefined - ? { feeTokenDecimals: options.feeTokenDecimals } - : {}), - ...(options.timeboostEnabled !== undefined - ? { timeboostEnabled: options.timeboostEnabled } - : {}), - }, - createInitContext(root), - ); + await runInitCommand(rebuildInitOptions(options), createInitContext(root)); return; } diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts index ff2e4f3..9477ca1 100644 --- a/apps/cli/src/commands/init.ts +++ b/apps/cli/src/commands/init.ts @@ -18,10 +18,6 @@ export const initCli = Cli.create("init", { .optional() .describe("Deploy a custom fee token ERC20 on L2 with this many decimals (6, 16, 18, or 20)"), foreground: z.boolean().optional().describe("Internal worker mode for detached init runs"), - nitroContractsVersion: z - .string() - .optional() - .describe("Nitro contracts version for the L3 rollup (e.g. v2.1, v3.2). Default v3.2."), rebuild: z .boolean() .optional() diff --git a/apps/cli/test/no-docker-rollup.test.ts b/apps/cli/test/no-docker-rollup.test.ts index 65e8881..f7d5731 100644 --- a/apps/cli/test/no-docker-rollup.test.ts +++ b/apps/cli/test/no-docker-rollup.test.ts @@ -18,7 +18,7 @@ describe("init chain steps deploy rollups through a local RollupCreator", () => it("uses Docker only to provision the RollupCreator contracts", () => { expect(initSource).toContain("deployRollupCreatorViaDocker"); - expect(initSource).toContain("CONTRACT_DEPLOYER_IMAGE"); + expect(initSource).toContain("resolveDeployerImageSpec"); expect(initSource).not.toContain("ROLLUPCREATOR_IMAGE"); }); diff --git a/bake/action.yml b/bake/action.yml index aa95b5e..31db16a 100644 --- a/bake/action.yml +++ b/bake/action.yml @@ -37,10 +37,17 @@ inputs: required: false default: "" description: "Base snapshot release tag to install (defaults to latest) when not rebuilding" - nitro-contracts-version: + nitro-contracts-ref: required: false - default: "v3.2" - description: "Nitro contracts version passed through to a rebuild init" + default: "v3.2.0" + description: >- + Nitro contracts release, branch, or commit used during a rebuild init. + fee-token-decimals: + required: false + default: "" + description: >- + Custom L3 fee-token decimals (6, 16, 18, or 20) for a rebuild init. + Empty = ETH-fee (no custom fee token). github-token: required: false default: "" @@ -93,12 +100,29 @@ runs: working-directory: ${{ github.action_path }}/.. env: BASE_SNAPSHOT_ID_INPUT: ${{ inputs.base-snapshot-id }} - NITRO_CONTRACTS_VERSION_INPUT: ${{ inputs.nitro-contracts-version }} + FEE_TOKEN_DECIMALS_INPUT: ${{ inputs.fee-token-decimals }} + NITRO_CONTRACTS_REF_INPUT: ${{ inputs.nitro-contracts-ref }} + NITRO_CONTRACTS_DEFAULT_COMMIT: 2695e7b3e3f460531e2b77fed48a60561c54d90e + NITRO_CONTRACTS_LOCAL_DIR: ${{ runner.temp }}/nitro-contracts TOKEN_BRIDGE_COMMIT: 5975d8f7360816341be7f94fd333ef240f4aec23 TOKEN_BRIDGE_LOCAL_DIR: ${{ runner.temp }}/token-bridge-contracts run: | set -euxo pipefail npm install -g yarn@1.22.22 + if [ ! -f "$NITRO_CONTRACTS_LOCAL_DIR/package.json" ]; then + mkdir -p "$NITRO_CONTRACTS_LOCAL_DIR" + ( + cd "$NITRO_CONTRACTS_LOCAL_DIR" + git init . + git remote add origin https://github.com/OffchainLabs/nitro-contracts.git + git fetch --depth 1 origin "$NITRO_CONTRACTS_REF_INPUT" + git checkout --detach FETCH_HEAD + git submodule update --init --recursive --depth 1 + if [ "$NITRO_CONTRACTS_REF_INPUT" = "v3.2.0" ]; then + test "$(git rev-parse HEAD)" = "$NITRO_CONTRACTS_DEFAULT_COMMIT" + fi + ) + fi if [ ! -f "$TOKEN_BRIDGE_LOCAL_DIR/node_modules/ts-node/dist/bin.js" ]; then mkdir -p "$TOKEN_BRIDGE_LOCAL_DIR" ( @@ -113,7 +137,9 @@ runs: fi base="$BASE_SNAPSHOT_ID_INPUT" init_args=(--rebuild --capture-id "$base" --skip-post-capture-verify) - init_args+=(--nitro-contracts-version "$NITRO_CONTRACTS_VERSION_INPUT") + if [ -n "$FEE_TOKEN_DECIMALS_INPUT" ]; then + init_args+=(--fee-token-decimals "$FEE_TOKEN_DECIMALS_INPUT") + fi for attempt in 1 2 3; do if node apps/cli/dist/index.js init "${init_args[@]}" \ && test -f "config/snapshots/$base/manifest.json"; then @@ -143,5 +169,4 @@ runs: --image-ref "${{ inputs.image-ref }}" \ --snapshot-id "${{ inputs.snapshot-id }}" \ --base-snapshot-id "${{ inputs.base-snapshot-id }}" \ - --nitro-contracts-version "${{ inputs.nitro-contracts-version }}" \ ${{ inputs.push == 'true' && '--push' || '' }} diff --git a/docker/contract-deployer-v2.1.Dockerfile b/docker/contract-deployer-v2.1.Dockerfile deleted file mode 100644 index d65d321..0000000 --- a/docker/contract-deployer-v2.1.Dockerfile +++ /dev/null @@ -1,38 +0,0 @@ -FROM ghcr.io/foundry-rs/foundry:v1.3.1 AS foundry - -FROM node:20-trixie-slim AS nitro-builder - -COPY --from=foundry /usr/local/bin/forge /usr/local/bin/forge - -RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* - -WORKDIR /workspace/nitro-contracts - -RUN git init . \ - && git remote add origin https://github.com/OffchainLabs/nitro-contracts.git \ - && git fetch --depth 1 origin f9cd1aa4b5bba209211e8df9993e0eba89eaedda \ - && git checkout --detach FETCH_HEAD \ - && git submodule update --init --recursive --depth 1 - -RUN cp scripts/config.ts.example scripts/config.ts -RUN yarn install --frozen-lockfile -# Hardhat compile produces the Solidity artifacts the deploy script consumes -# (hardhat run --no-compile). The forge SOL build is skipped because -# nitro-contracts v2.1.3 (Feb 2025) won't compile under foundry v1.3.1, but the -# forge YUL build is still required: deploymentUtils.ts loads the yul artifact -# out/yul/Reader4844.yul/Reader4844.json (compiled from yul/Reader4844.yul). -# forge v1.3.1 compiles the yul artifact successfully but then exits non-zero -# with a spurious "no Solidity sources" (because --skip *.sol leaves no .sol -# sources). Tolerate that exit, then assert the artifact was actually produced. -RUN yarn build && (yarn build:forge:yul || true) && test -f out/yul/Reader4844.yul/Reader4844.json - -FROM node:20-trixie-slim - -COPY --from=foundry /usr/local/bin/forge /usr/local/bin/forge - -WORKDIR /workspace -COPY --from=nitro-builder /workspace/nitro-contracts /workspace/nitro-contracts -COPY deploy-rollup-creator-v2.1.ts /workspace/nitro-contracts/scripts/local-deployment/deployRollupCreatorOnly.ts - -WORKDIR /workspace/nitro-contracts -ENTRYPOINT ["yarn"] diff --git a/docker/contract-deployer.Dockerfile b/docker/contract-deployer.Dockerfile index 9d23bed..5ab6b46 100644 --- a/docker/contract-deployer.Dockerfile +++ b/docker/contract-deployer.Dockerfile @@ -1,5 +1,9 @@ FROM ghcr.io/foundry-rs/foundry:v1.3.1 AS foundry +# The caller must override this stage with +# `--build-context nitrocontracts=`. +FROM scratch AS nitrocontracts + FROM node:20-trixie-slim AS nitro-builder COPY --from=foundry /usr/local/bin/forge /usr/local/bin/forge @@ -8,11 +12,7 @@ RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* WORKDIR /workspace/nitro-contracts -RUN git init . \ - && git remote add origin https://github.com/OffchainLabs/nitro-contracts.git \ - && git fetch --depth 1 origin cd4eb69e3c4cb87161b1433ad238902ea5c32ebd \ - && git checkout --detach FETCH_HEAD \ - && git submodule update --init --recursive --depth 1 +COPY --from=nitrocontracts . /workspace/nitro-contracts RUN cp scripts/config.example.ts scripts/config.ts RUN yarn install --frozen-lockfile diff --git a/packages/action/test/action.test.ts b/packages/action/test/action.test.ts index b4409ea..34b016e 100644 --- a/packages/action/test/action.test.ts +++ b/packages/action/test/action.test.ts @@ -107,14 +107,12 @@ describe("bake action metadata", () => { expect(action).not.toContain("OffchainLabs/arbitrum-testnode"); }); - it("passes the selected Nitro contracts version to every rebuild init attempt", () => { - expect(action).toContain('default: "v3.2"'); - expect(action).toContain( - "NITRO_CONTRACTS_VERSION_INPUT: ${{ inputs.nitro-contracts-version }}", - ); - expect(action).toContain( - 'init_args+=(--nitro-contracts-version "$NITRO_CONTRACTS_VERSION_INPUT")', - ); + it("prepares the selected Nitro source without passing a version selector to init", () => { + expect(action).toContain('default: "v3.2.0"'); + expect(action).toContain("NITRO_CONTRACTS_REF_INPUT: ${{ inputs.nitro-contracts-ref }}"); + expect(action).toContain("NITRO_CONTRACTS_LOCAL_DIR: ${{ runner.temp }}/nitro-contracts"); + expect(action).not.toContain("--nitro-contracts-version"); + expect(action).not.toContain("--nitro-contracts-branch"); expect(action).toContain('node apps/cli/dist/index.js init "${init_args[@]}"'); }); }); diff --git a/packages/core/src/init/chain-steps.ts b/packages/core/src/init/chain-steps.ts index ee6d757..53ecb63 100644 --- a/packages/core/src/init/chain-steps.ts +++ b/packages/core/src/init/chain-steps.ts @@ -26,6 +26,11 @@ import { } from "../token-bridge.js"; import { ensureValidatorWalletStaked } from "../validator-wallet.js"; import type { InitRuntime } from "./context.js"; +import { + type DeployerImageSpec, + type NitroContractsSource, + resolveDeployerImageSpec, +} from "./nitro-contracts-source.js"; const L1_RPC = "http://127.0.0.1:8545"; const L1_BEACON_RPC = "http://127.0.0.1:5555"; @@ -43,7 +48,6 @@ const L3_DEPOSIT_TARGET_WEI = 50n * 10n ** 18n; const L3_DEPOSIT_RESERVE_WEI = 1n * 10n ** 18n; const L3_DEPOSIT_READY_THRESHOLD_WEI = 10n * 10n ** 18n; const L2_OWNER_DEPLOYER_FUNDING_WEI = 100n * 10n ** 18n; -const CONTRACT_DEPLOYER_IMAGE = "nitro-testnode-contract-deployer:latest"; const CONTRACT_DEPLOYER_POLLING_INTERVAL_MS = 100; const CONTRACT_DEPLOYER_CREATE2_CONFIRMATIONS = 1; const WASM_MODULE_ROOT = "0xdb698a2576298f25448bc092e52cf13b1e24141c997135d70f217d674bbeb69a"; @@ -78,19 +82,24 @@ import { const builtContractDeployerImages = new Set(); -async function ensureContractDeployerImage( +export async function ensureContractDeployerImage( runtime: InitRuntime, - image: string = CONTRACT_DEPLOYER_IMAGE, - dockerfile = "docker/contract-deployer.Dockerfile", + spec: DeployerImageSpec, forceRebuild = false, + commands: { exec: typeof exec; execOrThrow: typeof execOrThrow } = { exec, execOrThrow }, ): Promise { - if (builtContractDeployerImages.has(image) && !forceRebuild) { - console.log(`[init] Contract deployer image already checked: ${image}`); + const { image, dockerfile, buildContext, reuseImage } = spec; + // forceRebuild (stale-image recovery) must rebuild even if we already built + // this tag this run; drop the in-run marker so the build below actually runs. + if (forceRebuild) { + builtContractDeployerImages.delete(image); + } else if (builtContractDeployerImages.has(image)) { + console.log(`[init] Contract deployer image already built this run: ${image}`); return; } - if (!forceRebuild) { + if (!forceRebuild && reuseImage) { console.log(`[init] Checking contract deployer image: ${image}`); - const inspect = exec("docker", ["image", "inspect", image], { + const inspect = commands.exec("docker", ["image", "inspect", image], { timeout: 30_000, }); if (inspect.exitCode === 0) { @@ -99,12 +108,17 @@ async function ensureContractDeployerImage( return; } } + // Named contexts require BuildKit. Set this explicitly so older daemons do + // not fall back to the classic builder. + process.env["DOCKER_BUILDKIT"] ??= "1"; console.log(`[init] Building contract deployer image: ${image}`); - execOrThrow( + commands.execOrThrow( "docker", [ "build", "--progress=plain", + "--build-context", + `nitrocontracts=${buildContext}`, "-t", image, "-f", @@ -124,15 +138,14 @@ async function deployRollupCreatorViaDocker( dockerParentRpc: string; deployerKey: string; maxDataSize: string; - image?: string; - dockerfile?: string; + nitroContractsSource: NitroContractsSource; retryAfterImageRebuild?: boolean; }, ): Promise { const retryAfterImageRebuild = params.retryAfterImageRebuild ?? true; - const image = params.image ?? CONTRACT_DEPLOYER_IMAGE; - const dockerfile = params.dockerfile ?? "docker/contract-deployer.Dockerfile"; - await ensureContractDeployerImage(runtime, image, dockerfile); + const spec = resolveDeployerImageSpec(params.nitroContractsSource); + const image = spec.image; + await ensureContractDeployerImage(runtime, spec); await waitForRpc(params.hostParentRpc); console.log(`[init] Deploying RollupCreator on ${params.dockerParentRpc}`); const args = [ @@ -173,7 +186,7 @@ async function deployRollupCreatorViaDocker( if (!output.stakeToken) { if (retryAfterImageRebuild) { console.warn("[init] Contract deployer image is stale; rebuilding and retrying once"); - await ensureContractDeployerImage(runtime, image, dockerfile, true); + await ensureContractDeployerImage(runtime, spec, true); return deployRollupCreatorViaDocker(runtime, { ...params, retryAfterImageRebuild: false, @@ -195,9 +208,11 @@ async function deployTimeboostAuctionViaDocker( hostRpc: string; dockerRpc: string; deployerKey: string; + nitroContractsSource: NitroContractsSource; }, ): Promise { - await ensureContractDeployerImage(runtime); + const spec = resolveDeployerImageSpec(params.nitroContractsSource); + await ensureContractDeployerImage(runtime, spec); await waitForRpc(params.hostRpc); console.log(`[init] Deploying Timeboost auction contract on ${params.dockerRpc}`); const args = [ @@ -227,7 +242,7 @@ async function deployTimeboostAuctionViaDocker( `TIMEBOOST_BENEFICIARY_ADDRESS=${accounts.l2owner.address}`, "-e", "TIMEBOOST_AUCTION_OUTPUT=/config/timeboost-auction.json", - CONTRACT_DEPLOYER_IMAGE, + spec.image, "hardhat", "run", "--no-compile", @@ -286,7 +301,10 @@ function createL1Steps(runtime: InitRuntime): Record { }; } -function createL2DeploySteps(runtime: InitRuntime): Record { +function createL2DeploySteps( + runtime: InitRuntime, + nitroContractsSource: NitroContractsSource, +): Record { return { "deploy-l2-rollup": async (state) => { writeChainConfig(runtime.configDir, "l2_chain_config.json", { @@ -298,6 +316,7 @@ function createL2DeploySteps(runtime: InitRuntime): Record { dockerParentRpc: L1_RPC_DOCKER, deployerKey: accounts.l2owner.privateKey, maxDataSize: "117964", + nitroContractsSource, }); await deployRollupViaSdk({ chainConfigPath: resolve(runtime.configDir, "l2_chain_config.json"), @@ -382,7 +401,10 @@ function createL2DeploySteps(runtime: InitRuntime): Record { }; } -function createL2RuntimeSteps(runtime: InitRuntime): Record { +function createL2RuntimeSteps( + runtime: InitRuntime, + nitroContractsSource: NitroContractsSource, +): Record { return { "start-l2": async (state) => { composeUp(["sequencer", "validator"], runtime.dockerOpts); @@ -397,6 +419,7 @@ function createL2RuntimeSteps(runtime: InitRuntime): Record hostRpc: L2_RPC, dockerRpc: L2_RPC_DOCKER, deployerKey: accounts.l2owner.privateKey, + nitroContractsSource, }); return markStepDone(state, "deploy-timeboost-auction", { ...deployment }); }, @@ -509,7 +532,6 @@ async function fundL3DeployerAccounts(): Promise { async function deployCustomFeeToken( feeTokenDecimals?: number, - deployPricer = true, ): Promise<{ feeTokenAddress?: string; feeTokenPricerAddress?: string }> { if (feeTokenDecimals === undefined) { return {}; @@ -531,10 +553,6 @@ async function deployCustomFeeToken( console.log( `[init] Custom fee token deployed at ${feeTokenAddress} with ${feeTokenDecimals} decimals`, ); - if (!deployPricer) { - // v2.1 AnyTrust custom-gas rollups have no feeTokenPricer parameter. - return { feeTokenAddress }; - } // Custom-gas Rollup chains require a non-zero feeTokenPricer. // Deploy a constant-rate pricer on the parent chain (L2), using // the same deployer key the rollup uses. @@ -585,33 +603,24 @@ async function deployL3Rollup( state: InitState, runtime: InitRuntime, feeTokenDecimals: number | undefined, - isV21: boolean, + nitroContractsSource: NitroContractsSource, ): Promise { await fundL3DeployerAccounts(); writeChainConfig(runtime.configDir, "l3_chain_config.json", { chainId: 333333, owner: accounts.l3owner.address, - ...(isV21 ? { dataAvailabilityCommittee: true } : {}), }); await applyGasEstimationWorkaround(); - // If custom fee token is requested, deploy an ERC20 (+ pricer for v3.2) on L2 - const { feeTokenAddress, feeTokenPricerAddress } = await deployCustomFeeToken( - feeTokenDecimals, - !isV21, - ); + // If custom fee token is requested, deploy an ERC20 and pricer on L2. + const { feeTokenAddress, feeTokenPricerAddress } = await deployCustomFeeToken(feeTokenDecimals); const rollupCreatorDeployment = await deployRollupCreatorViaDocker(runtime, { hostParentRpc: L2_RPC, dockerParentRpc: L2_RPC_DOCKER, deployerKey: accounts.l3owner.privateKey, maxDataSize: "104857", - ...(isV21 - ? { - image: "nitro-testnode-contract-deployer-v2.1:latest", - dockerfile: "docker/contract-deployer-v2.1.Dockerfile", - } - : {}), + nitroContractsSource, }); await deployRollupViaSdk({ chainConfigPath: resolve(runtime.configDir, "l3_chain_config.json"), @@ -633,7 +642,7 @@ async function deployL3Rollup( rawNodeConfigOutputPath: resolve(runtime.configDir, "l3-nodeConfig.raw.json"), rollupCreatorAddress: rollupCreatorDeployment.rollupCreator, stakeToken: rollupCreatorDeployment.stakeToken, - nitroContractsVersion: isV21 ? "v2.1" : "v3.2", + nitroContractsVersion: nitroContractsSource.family, ...(feeTokenAddress ? { nativeToken: feeTokenAddress as `0x${string}` } : {}), ...(feeTokenPricerAddress ? { feeTokenPricer: feeTokenPricerAddress as `0x${string}` } : {}), }); @@ -655,12 +664,12 @@ async function deployL3Rollup( function createL3Steps( runtime: InitRuntime, - feeTokenDecimals?: number, - nitroContractsVersion?: string, + feeTokenDecimals: number | undefined, + nitroContractsSource: NitroContractsSource, ): Record { - const isV21 = nitroContractsVersion === "v2.1"; return { - "deploy-l3-rollup": (state) => deployL3Rollup(state, runtime, feeTokenDecimals, isV21), + "deploy-l3-rollup": (state) => + deployL3Rollup(state, runtime, feeTokenDecimals, nitroContractsSource), "generate-l3-config": async (state) => { const rollupData = state.steps["deploy-l3-rollup"]?.data; if (!rollupData) { @@ -819,13 +828,15 @@ function createL3Steps( export function makeStepRunners( runtime: InitRuntime, - feeTokenDecimals?: number, - nitroContractsVersion?: string, + options: { + feeTokenDecimals?: number | undefined; + nitroContractsSource: NitroContractsSource; + }, ): Record { return { ...createL1Steps(runtime), - ...createL2DeploySteps(runtime), - ...createL2RuntimeSteps(runtime), - ...createL3Steps(runtime, feeTokenDecimals, nitroContractsVersion), + ...createL2DeploySteps(runtime, options.nitroContractsSource), + ...createL2RuntimeSteps(runtime, options.nitroContractsSource), + ...createL3Steps(runtime, options.feeTokenDecimals, options.nitroContractsSource), }; } diff --git a/packages/core/src/init/nitro-contracts-source.ts b/packages/core/src/init/nitro-contracts-source.ts new file mode 100644 index 0000000..0aba052 --- /dev/null +++ b/packages/core/src/init/nitro-contracts-source.ts @@ -0,0 +1,149 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +export const DEFAULT_NITRO_CONTRACTS_RELEASE = "v3.2.0"; +export const DEFAULT_NITRO_CONTRACTS_COMMIT = "2695e7b3e3f460531e2b77fed48a60561c54d90e"; +export const NITRO_CONTRACTS_FAMILY = "v3.2"; + +const NITRO_CONTRACTS_REPOSITORY = "https://github.com/OffchainLabs/nitro-contracts.git"; +const NITRO_CONTRACTS_PACKAGE = "@arbitrum/nitro-contracts"; + +export type NitroContractsSource = + | { + kind: "release"; + family: typeof NITRO_CONTRACTS_FAMILY; + release: typeof DEFAULT_NITRO_CONTRACTS_RELEASE; + commit: typeof DEFAULT_NITRO_CONTRACTS_COMMIT; + buildContext: string; + identity: string; + } + | { + kind: "workspace"; + family: typeof NITRO_CONTRACTS_FAMILY; + path: string; + packageVersion: string; + buildContext: string; + identity: string; + }; + +interface NitroContractsPackageJson { + name?: unknown; + version?: unknown; + scripts?: Record | undefined; +} + +function readPackageJson(workspace: string): NitroContractsPackageJson { + const path = resolve(workspace, "package.json"); + if (!existsSync(path)) { + throw new Error( + `Nitro contracts workspace is missing package.json: ${path}. Set NITRO_CONTRACTS_LOCAL_DIR to a nitro-contracts checkout.`, + ); + } + try { + return JSON.parse(readFileSync(path, "utf8")) as NitroContractsPackageJson; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Unable to parse Nitro contracts package.json at ${path}: ${message}`); + } +} + +function assertRequiredPath(workspace: string, relativePath: string): void { + const path = resolve(workspace, relativePath); + if (!existsSync(path)) { + throw new Error(`Nitro contracts workspace is missing ${relativePath}: ${path}`); + } +} + +export function validateNitroContractsWorkspace(workspace: string): { + path: string; + packageVersion: string; +} { + const path = resolve(workspace); + const packageJson = readPackageJson(path); + if (packageJson.name !== NITRO_CONTRACTS_PACKAGE) { + throw new Error( + `Expected ${NITRO_CONTRACTS_PACKAGE} in ${resolve(path, "package.json")}, ` + + `found ${JSON.stringify(packageJson.name)}`, + ); + } + if (typeof packageJson.version !== "string") { + throw new Error(`Nitro contracts package version must be a semantic version string: ${path}`); + } + const versionMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].+)?$/.exec(packageJson.version); + if (!versionMatch) { + throw new Error(`Unsupported Nitro contracts package version ${packageJson.version}`); + } + if (Number(versionMatch[1]) !== 3) { + throw new Error( + `Nitro contracts ${packageJson.version} is not supported for new builds; expected a 3.x checkout`, + ); + } + if (typeof packageJson.scripts?.["build"] !== "string") { + throw new Error(`Nitro contracts workspace does not define the required build script: ${path}`); + } + if (typeof packageJson.scripts?.["build:all"] !== "string") { + throw new Error( + `Nitro contracts workspace does not define the required build:all script: ${path}`, + ); + } + assertRequiredPath(path, "yarn.lock"); + assertRequiredPath(path, "scripts/config.example.ts"); + assertRequiredPath(path, "src/precompiles/ArbGasInfo.sol"); + assertRequiredPath(path, "lib/forge-std/src/Test.sol"); + return { path, packageVersion: packageJson.version }; +} + +function workspaceSource(workspace: string): NitroContractsSource { + const validated = validateNitroContractsWorkspace(workspace); + return { + kind: "workspace", + family: NITRO_CONTRACTS_FAMILY, + path: validated.path, + packageVersion: validated.packageVersion, + buildContext: validated.path, + identity: `workspace:${validated.path}`, + }; +} + +export function resolveNitroContractsSource( + projectRoot: string, + env: NodeJS.ProcessEnv = process.env, +): NitroContractsSource { + const configured = env["NITRO_CONTRACTS_LOCAL_DIR"]?.trim(); + if (configured) { + return workspaceSource(configured); + } + + const sibling = resolve(projectRoot, "..", "nitro-contracts"); + if (existsSync(sibling)) { + return workspaceSource(sibling); + } + + return { + kind: "release", + family: NITRO_CONTRACTS_FAMILY, + release: DEFAULT_NITRO_CONTRACTS_RELEASE, + commit: DEFAULT_NITRO_CONTRACTS_COMMIT, + buildContext: `${NITRO_CONTRACTS_REPOSITORY}#${DEFAULT_NITRO_CONTRACTS_COMMIT}`, + identity: `release:${DEFAULT_NITRO_CONTRACTS_RELEASE}@${DEFAULT_NITRO_CONTRACTS_COMMIT}`, + }; +} + +export interface DeployerImageSpec { + image: string; + dockerfile: string; + buildContext: string; + reuseImage: boolean; +} + +export function resolveDeployerImageSpec(source: NitroContractsSource): DeployerImageSpec { + const identityHash = createHash("sha256").update(source.identity).digest("hex").slice(0, 12); + const label = source.kind === "release" ? source.release : source.packageVersion; + return { + image: `nitro-testnode-contract-deployer:${label}-${identityHash}`, + dockerfile: "docker/contract-deployer.Dockerfile", + buildContext: source.buildContext, + reuseImage: source.kind === "release", + }; +} diff --git a/packages/core/src/init/runner.ts b/packages/core/src/init/runner.ts index 36249fd..77e6845 100644 --- a/packages/core/src/init/runner.ts +++ b/packages/core/src/init/runner.ts @@ -23,6 +23,7 @@ import { import { createState, getNextPendingStep, loadState, markStepFailed, saveState } from "../state.js"; import { makeStepRunners } from "./chain-steps.js"; import { type InitContext, type InitRuntime, createInitRuntime } from "./context.js"; +import { resolveNitroContractsSource } from "./nitro-contracts-source.js"; import { INIT_STEP_NAMES, getInitSteps } from "./steps.js"; export { createInitContext, type InitContext } from "./context.js"; @@ -57,10 +58,12 @@ async function waitForAnvilStateFile(configDir: string): Promise { async function runInitLoop( runtime: InitRuntime, - feeTokenDecimals?: number, - rebuild?: boolean, - timeboostEnabled?: boolean, - nitroContractsVersion?: string, + options: { + feeTokenDecimals?: number | undefined; + rebuild?: boolean | undefined; + timeboostEnabled?: boolean | undefined; + nitroContractsSource: ReturnType; + }, ): Promise<{ success: boolean; failedStep?: string; @@ -68,9 +71,12 @@ async function runInitLoop( timings?: Record; steps: string[]; }> { - let state = rebuild ? createState() : (loadState(runtime.configDir) ?? createState()); - const runners = makeStepRunners(runtime, feeTokenDecimals, nitroContractsVersion); - const steps = getInitSteps({ timeboostEnabled }); + let state = options.rebuild ? createState() : (loadState(runtime.configDir) ?? createState()); + const runners = makeStepRunners(runtime, { + feeTokenDecimals: options.feeTokenDecimals, + nitroContractsSource: options.nitroContractsSource, + }); + const steps = getInitSteps({ timeboostEnabled: options.timeboostEnabled }); const timings: Record = {}; let nextStep = getNextPendingStep(state, steps); @@ -117,7 +123,6 @@ export interface InitCommandOptions { captureId?: string | undefined; feeTokenDecimals?: number | undefined; foreground?: boolean | undefined; - nitroContractsVersion?: string | undefined; rebuild?: boolean | undefined; skipPostCaptureVerify?: boolean | undefined; snapshotVersion?: string | undefined; @@ -152,7 +157,6 @@ export async function runInitCommand(options: InitCommandOptions, context: InitC snapshotVersion: options.snapshotVersion, feeTokenDecimals, timeboostEnabled: options.timeboostEnabled, - nitroContractsVersion: options.nitroContractsVersion, }); } @@ -177,7 +181,6 @@ async function runInitForeground( runtime: InitRuntime, options: { foreground?: boolean | undefined; - nitroContractsVersion?: string | undefined; rebuild?: boolean | undefined; skipPostCaptureVerify?: boolean | undefined; snapshotVersion?: string | undefined; @@ -212,13 +215,14 @@ async function runInitForeground( } startRunLoggingFromEnv(runtime.configDir) ?? startInlineRunLogging(runtime.configDir, logArgs); - const result = await runInitLoop( - runtime, + const nitroContractsSource = resolveNitroContractsSource(runtime.projectRoot); + console.log(`[init] Nitro contracts source: ${nitroContractsSource.identity}`); + const result = await runInitLoop(runtime, { feeTokenDecimals, - options.rebuild, - options.timeboostEnabled, - options.nitroContractsVersion, - ); + rebuild: options.rebuild, + timeboostEnabled: options.timeboostEnabled, + nitroContractsSource, + }); const totalElapsed = Date.now() - totalStart; logInitTimeline(result.timings, totalElapsed); @@ -232,6 +236,7 @@ async function runInitForeground( totalStart, result.steps, options.skipPostCaptureVerify, + nitroContractsSource.family, ); } @@ -274,7 +279,6 @@ function startBackgroundInit( snapshotVersion: string | undefined; feeTokenDecimals: number | undefined; timeboostEnabled: boolean | undefined; - nitroContractsVersion: string | undefined; }, ) { const extraArgs = [ @@ -283,9 +287,6 @@ function startBackgroundInit( ? ["--fee-token-decimals", String(params.feeTokenDecimals)] : []), ...(params.timeboostEnabled ? ["--timeboost-enabled"] : []), - ...(params.nitroContractsVersion - ? ["--nitro-contracts-version", params.nitroContractsVersion] - : []), ]; const run = startDetachedInitRun(runtime.configDir, runtime.projectRoot, extraArgs); return { @@ -370,6 +371,7 @@ async function finalizeFreshInit( totalStart: number, steps: string[], skipPostCaptureVerify?: boolean, + nitroContractsVersion?: string, ) { stopRuntime({ composeFile: runtime.composeFile, @@ -377,7 +379,9 @@ async function finalizeFreshInit( configDir: runtime.configDir, }); await waitForAnvilStateFile(runtime.configDir); - const snapshot = captureSnapshot(runtime.configDir, runtime.composeFile, snapshotId); + const snapshot = captureSnapshot(runtime.configDir, runtime.composeFile, snapshotId, { + nitroContractsVersion, + }); if (skipPostCaptureVerify) { const totalElapsed = Date.now() - totalStart; finishActiveRun("completed", { exitCode: 0 }); diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index d0af153..5d52243 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -241,6 +241,7 @@ export function buildSnapshotManifest( configDir: string, composeFile: string, snapshotId = DEFAULT_SNAPSHOT_ID, + metadata: { nitroContractsVersion?: string | undefined } = {}, ): SnapshotManifest { assertRequiredConfigFiles(configDir); assertNonEmptyFile( @@ -268,6 +269,9 @@ export function buildSnapshotManifest( l2: l2Deployment.rollup, l3: l3Deployment.rollup, }, + ...(metadata.nitroContractsVersion + ? { nitroContractsVersion: metadata.nitroContractsVersion } + : {}), requiredFiles: [ ...CRITICAL_CONFIG_FILES.map((file) => join("config", file)), join(ANVIL_STATE_DIRNAME, ANVIL_STATE_FILENAME), @@ -315,6 +319,7 @@ export function captureSnapshot( configDir: string, composeFile: string, snapshotId = DEFAULT_SNAPSHOT_ID, + metadata: { nitroContractsVersion?: string | undefined } = {}, ): SnapshotManifest { assertRequiredConfigFiles(configDir); const snapshotDir = getSnapshotDir(configDir, snapshotId); @@ -331,7 +336,7 @@ export function captureSnapshot( exportDockerVolume(volumeName, join(snapshotVolumesDir, archiveName)); } - const manifest = buildSnapshotManifest(configDir, composeFile, snapshotId); + const manifest = buildSnapshotManifest(configDir, composeFile, snapshotId, metadata); writeFileSync( getSnapshotManifestPath(configDir, snapshotId), `${JSON.stringify(manifest, null, 2)}\n`, diff --git a/packages/core/test/deployer-image-spec.test.ts b/packages/core/test/deployer-image-spec.test.ts new file mode 100644 index 0000000..363eb60 --- /dev/null +++ b/packages/core/test/deployer-image-spec.test.ts @@ -0,0 +1,171 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ensureContractDeployerImage } from "../src/init/chain-steps.js"; +import { + DEFAULT_NITRO_CONTRACTS_COMMIT, + DEFAULT_NITRO_CONTRACTS_RELEASE, + resolveDeployerImageSpec, + resolveNitroContractsSource, + validateNitroContractsWorkspace, +} from "../src/init/nitro-contracts-source.js"; + +const tempDirs: string[] = []; + +function makeProject(): { root: string; parent: string } { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "nitro-source-test-")); + tempDirs.push(parent); + const root = path.join(parent, "arbitrum-testnode"); + fs.mkdirSync(root); + return { root, parent }; +} + +function makeWorkspace( + dir: string, + overrides: { name?: string; version?: string; scripts?: Record } = {}, +): string { + fs.mkdirSync(path.join(dir, "scripts"), { recursive: true }); + fs.mkdirSync(path.join(dir, "src/precompiles"), { recursive: true }); + fs.mkdirSync(path.join(dir, "lib/forge-std/src"), { recursive: true }); + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ + name: overrides.name ?? "@arbitrum/nitro-contracts", + version: overrides.version ?? "3.2.0", + scripts: overrides.scripts ?? { build: "hardhat compile", "build:all": "yarn build" }, + }), + ); + fs.writeFileSync(path.join(dir, "yarn.lock"), ""); + fs.writeFileSync(path.join(dir, "scripts/config.example.ts"), "export default {};"); + fs.writeFileSync(path.join(dir, "src/precompiles/ArbGasInfo.sol"), ""); + fs.writeFileSync(path.join(dir, "lib/forge-std/src/Test.sol"), ""); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("resolveNitroContractsSource", () => { + it("defaults to the pinned stable release", () => { + const { root } = makeProject(); + const source = resolveNitroContractsSource(root, {}); + expect(source).toEqual({ + kind: "release", + family: "v3.2", + release: DEFAULT_NITRO_CONTRACTS_RELEASE, + commit: DEFAULT_NITRO_CONTRACTS_COMMIT, + buildContext: `https://github.com/OffchainLabs/nitro-contracts.git#${DEFAULT_NITRO_CONTRACTS_COMMIT}`, + identity: `release:${DEFAULT_NITRO_CONTRACTS_RELEASE}@${DEFAULT_NITRO_CONTRACTS_COMMIT}`, + }); + }); + + it("prefers an explicit workspace over the sibling checkout", () => { + const { root, parent } = makeProject(); + makeWorkspace(path.join(parent, "nitro-contracts")); + const explicit = makeWorkspace(path.join(parent, "custom-nitro"), { version: "3.9.1" }); + const source = resolveNitroContractsSource(root, { NITRO_CONTRACTS_LOCAL_DIR: explicit }); + expect(source.kind).toBe("workspace"); + if (source.kind === "workspace") { + expect(source.path).toBe(explicit); + expect(source.packageVersion).toBe("3.9.1"); + } + }); + + it("uses a valid sibling checkout when no override is set", () => { + const { root, parent } = makeProject(); + const sibling = makeWorkspace(path.join(parent, "nitro-contracts")); + const source = resolveNitroContractsSource(root, {}); + expect(source.kind).toBe("workspace"); + if (source.kind === "workspace") { + expect(source.path).toBe(sibling); + } + }); + + it("does not silently ignore an invalid sibling checkout", () => { + const { root, parent } = makeProject(); + fs.mkdirSync(path.join(parent, "nitro-contracts")); + expect(() => resolveNitroContractsSource(root, {})).toThrow(/missing package.json/); + }); +}); + +describe("validateNitroContractsWorkspace", () => { + it("rejects an unrelated package", () => { + const { parent } = makeProject(); + const workspace = makeWorkspace(path.join(parent, "wrong"), { name: "not-nitro" }); + expect(() => validateNitroContractsWorkspace(workspace)).toThrow(/@arbitrum\/nitro-contracts/); + }); + + it("rejects legacy and unsupported future families", () => { + const { parent } = makeProject(); + const legacy = makeWorkspace(path.join(parent, "legacy"), { version: "2.1.3" }); + const future = makeWorkspace(path.join(parent, "future"), { version: "4.0.0" }); + expect(() => validateNitroContractsWorkspace(legacy)).toThrow(/expected a 3.x checkout/); + expect(() => validateNitroContractsWorkspace(future)).toThrow(/expected a 3.x checkout/); + }); + + it("rejects malformed versions and incomplete workspaces", () => { + const { parent } = makeProject(); + const malformed = makeWorkspace(path.join(parent, "malformed"), { version: "main" }); + expect(() => validateNitroContractsWorkspace(malformed)).toThrow(/Unsupported/); + + const incomplete = makeWorkspace(path.join(parent, "incomplete")); + fs.rmSync(path.join(incomplete, "scripts/config.example.ts")); + expect(() => validateNitroContractsWorkspace(incomplete)).toThrow(/scripts\/config.example.ts/); + }); +}); + +describe("resolveDeployerImageSpec", () => { + it("reuses an immutable release image", () => { + const { root } = makeProject(); + const spec = resolveDeployerImageSpec(resolveNitroContractsSource(root, {})); + expect(spec.image).toMatch(/^nitro-testnode-contract-deployer:v3\.2\.0-[a-f0-9]{12}$/); + expect(spec.buildContext).toContain(DEFAULT_NITRO_CONTRACTS_COMMIT); + expect(spec.reuseImage).toBe(true); + }); + + it("always rebuilds a workspace image so BuildKit sees content changes", () => { + const { root, parent } = makeProject(); + const workspace = makeWorkspace(path.join(parent, "workspace")); + const source = resolveNitroContractsSource(root, { NITRO_CONTRACTS_LOCAL_DIR: workspace }); + const spec = resolveDeployerImageSpec(source); + expect(spec.buildContext).toBe(workspace); + expect(spec.reuseImage).toBe(false); + }); + + it("builds a workspace image only once per init run", async () => { + const { root, parent } = makeProject(); + const workspace = makeWorkspace(path.join(parent, "workspace")); + const source = resolveNitroContractsSource(root, { NITRO_CONTRACTS_LOCAL_DIR: workspace }); + const spec = resolveDeployerImageSpec(source); + const exec = vi.fn(); + const execOrThrow = vi.fn(); + const runtime = { projectRoot: root } as never; + const commands = { exec: exec as never, execOrThrow: execOrThrow as never }; + + await ensureContractDeployerImage(runtime, spec, false, commands); + await ensureContractDeployerImage(runtime, spec, false, commands); + + expect(exec).not.toHaveBeenCalled(); + expect(execOrThrow).toHaveBeenCalledTimes(1); + }); +}); + +describe("contract deployer Dockerfile", () => { + const contents = fs.readFileSync(path.resolve("docker/contract-deployer.Dockerfile"), "utf8"); + + it("consumes the caller-provided named context", () => { + expect(contents).toContain("FROM scratch AS nitrocontracts"); + expect(contents).toContain("COPY --from=nitrocontracts . /workspace/nitro-contracts"); + }); + + it("contains no hidden Nitro ref or dynamic source selector", () => { + expect(contents).not.toContain("NITRO_CONTRACTS_BRANCH"); + expect(contents).not.toContain("NITRO_CONTRACTS_SOURCE"); + expect(contents).not.toContain("github.com/OffchainLabs/nitro-contracts"); + expect(fs.existsSync(path.resolve("docker/contract-deployer-v2.1.Dockerfile"))).toBe(false); + }); +}); diff --git a/packages/testnode/src/runtime.mjs b/packages/testnode/src/runtime.mjs index 77f346a..5395422 100644 --- a/packages/testnode/src/runtime.mjs +++ b/packages/testnode/src/runtime.mjs @@ -479,7 +479,9 @@ function buildPublishRow(def, contractsVersion) { */ function publishRowsForVariant(def, versionFilter) { const versions = (def.supportedContractsVersions ?? []).filter( - (version) => versionFilter === "all" || version === versionFilter, + (version) => + version === DEFAULT_NITRO_CONTRACTS_VERSION && + (versionFilter === "all" || version === versionFilter), ); return versions.map((version) => buildPublishRow(def, version)); } diff --git a/packages/testnode/test/testnode.test.ts b/packages/testnode/test/testnode.test.ts index 733ae57..613a2f2 100644 --- a/packages/testnode/test/testnode.test.ts +++ b/packages/testnode/test/testnode.test.ts @@ -343,24 +343,20 @@ describe("resolvePublishMatrix", () => { const key = (row: { variant: string; contractsVersion: string }) => `${row.variant}@${row.contractsVersion}`; - it("emits exactly the 11 expected (variant × version) rows for all/all", () => { + it("emits only current-family rows for new publishes", () => { const rows = resolvePublishMatrix("all", "all"); expect(new Set(rows.map(key))).toEqual( new Set([ "l2@v3.2", "l2-timeboost@v3.2", "l3-eth@v3.2", - "l3-custom-6@v2.1", "l3-custom-6@v3.2", - "l3-custom-16@v2.1", "l3-custom-16@v3.2", - "l3-custom-18@v2.1", "l3-custom-18@v3.2", - "l3-custom-20@v2.1", "l3-custom-20@v3.2", ]), ); - expect(rows).toHaveLength(11); + expect(rows).toHaveLength(7); }); it("does not emit a v2.1 row for l2, l2-timeboost, or l3-eth", () => { @@ -371,16 +367,6 @@ describe("resolvePublishMatrix", () => { expect(v21.has("l3-eth")).toBe(false); }); - it("carries the snapshot id and fee-token decimals for a custom v2.1 row", () => { - const rows = resolvePublishMatrix("all", "all"); - const row = rows.find((r) => r.variant === "l3-custom-16" && r.contractsVersion === "v2.1"); - expect(row).toMatchObject({ - snapshotId: "l3-custom-16-v2.1", - feeTokenDecimals: 16, - timeboostEnabled: false, - }); - }); - it("marks the timeboost row and sets null fee-token decimals", () => { const rows = resolvePublishMatrix("all", "all"); const row = rows.find((r) => r.variant === "l2-timeboost"); @@ -391,17 +377,9 @@ describe("resolvePublishMatrix", () => { }); }); - it("returns exactly one row when filtering to a variant and version", () => { + it("does not produce new legacy rows", () => { const rows = resolvePublishMatrix("l3-custom-20", "v2.1"); - expect(rows).toEqual([ - { - variant: "l3-custom-20", - contractsVersion: "v2.1", - snapshotId: "l3-custom-20-v2.1", - feeTokenDecimals: 20, - timeboostEnabled: false, - }, - ]); + expect(rows).toEqual([]); }); it("returns no rows when the version is unsupported for the variant", () => {