diff --git a/.github/workflows/contracts-upgrade-version-check.yml b/.github/workflows/contracts-upgrade-version-check.yml new file mode 100644 index 0000000000..5dafe28c25 --- /dev/null +++ b/.github/workflows/contracts-upgrade-version-check.yml @@ -0,0 +1,118 @@ +name: contracts-upgrade-version-check + +permissions: {} + +on: + pull_request: + +# Compare PR bytecode against the last deployed release, not main. +# This avoids unnecessary reinitializer bumps when multiple PRs modify +# the same contract between deployments. Keep in sync with *-upgrade-tests.yml. +env: + UPGRADE_FROM_TAG: v0.11.0 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + check-changes: + name: contracts-upgrade-version-check/check-changes + permissions: + contents: 'read' # Required to checkout repository code + pull-requests: 'read' # Required to read pull request for paths-filter + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.filter.outputs.changes }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: 'false' + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + host-contracts: + - .github/workflows/contracts-upgrade-version-check.yml + - ci/check-upgrade-versions.ts + - ci/merge-address-constants.ts + - host-contracts/** + gateway-contracts: + - .github/workflows/contracts-upgrade-version-check.yml + - ci/check-upgrade-versions.ts + - ci/merge-address-constants.ts + - gateway-contracts/** + + check: + name: contracts-upgrade-version-check/${{ matrix.package }} (bpr) + needs: check-changes + if: ${{ needs.check-changes.outputs.packages != '[]' }} + permissions: + contents: 'read' # Required to checkout repository code + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.check-changes.outputs.packages) }} + include: + - package: host-contracts + extra-deps: forge soldeer install + - package: gateway-contracts + extra-deps: '' + steps: + - name: Checkout PR branch + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: 'false' + + - name: Checkout baseline (last deployed release) + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ env.UPGRADE_FROM_TAG }} + path: baseline + persist-credentials: 'false' + + - name: Install Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@82dee4ba654bd2146511f85f0d013af94670c4de # v1.4.0 + + - name: Install PR dependencies + working-directory: ${{ matrix.package }} + run: npm ci + + - name: Install baseline dependencies + working-directory: baseline/${{ matrix.package }} + run: npm ci + + - name: Install Forge dependencies + if: matrix.extra-deps != '' + env: + PACKAGE: ${{ matrix.package }} + EXTRA_DEPS: ${{ matrix.extra-deps }} + run: | + (cd "$PACKAGE" && $EXTRA_DEPS) + (cd "baseline/$PACKAGE" && $EXTRA_DEPS) + + - name: Setup compilation + env: + PACKAGE: ${{ matrix.package }} + run: | + # Generate addresses on both sides independently, then merge them. + # Address constants are embedded in bytecode, so both sides must compile + # with identical values. We can't just copy one side's addresses to the + # other because contracts may be added or removed between versions — the + # baseline would fail to compile if it references a removed constant, or + # the PR would fail if it references a new one. Merging gives both sides + # the full union of constants with consistent values (PR wins for shared). + (cd "$PACKAGE" && make ensure-addresses) + (cd "baseline/$PACKAGE" && make ensure-addresses) + bun ci/merge-address-constants.ts "baseline/$PACKAGE/addresses" "$PACKAGE/addresses" + # Use PR's foundry.toml for both so compiler settings match (cbor_metadata, bytecode_hash) + cp "$PACKAGE/foundry.toml" "baseline/$PACKAGE/foundry.toml" + + - name: Run upgrade version check + env: + PACKAGE: ${{ matrix.package }} + run: bun ci/check-upgrade-versions.ts "baseline/$PACKAGE" "$PACKAGE" diff --git a/ci/check-upgrade-versions.ts b/ci/check-upgrade-versions.ts new file mode 100644 index 0000000000..d76e92eac5 --- /dev/null +++ b/ci/check-upgrade-versions.ts @@ -0,0 +1,147 @@ +#!/usr/bin/env bun +// Checks that upgradeable contracts have proper version bumps when bytecode changes. +// Usage: bun ci/check-upgrade-versions.ts + +import { readFileSync, existsSync } from "fs"; +import { execSync } from "child_process"; +import { join } from "path"; + +const [baselineDir, prDir] = process.argv.slice(2); +if (!baselineDir || !prDir) { + console.error("Usage: bun ci/check-upgrade-versions.ts "); + process.exit(1); +} + +const manifestPath = join(prDir, "upgrade-manifest.json"); +if (!existsSync(manifestPath)) { + console.error(`::error::upgrade-manifest.json not found in ${prDir}`); + process.exit(1); +} + +const VERSION_RE = /(?REINITIALIZER_VERSION|MAJOR_VERSION|MINOR_VERSION|PATCH_VERSION)\s*=\s*(?\d+)/g; + +function extractVersions(filePath: string) { + const source = readFileSync(filePath, "utf-8"); + const versions: Record = {}; + for (const { groups } of source.matchAll(VERSION_RE)) { + versions[groups!.name] = Number(groups!.value); + } + return { versions, source }; +} + +function forgeInspect(contract: string, root: string): string | null { + try { + const raw = execSync(`forge inspect "contracts/${contract}.sol:${contract}" --root "${root}" deployedBytecode`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, NO_COLOR: "1" }, + }); + // Extract hex bytecode — forge may prepend ANSI codes or compilation progress to stdout + const match = raw.match(/0x[0-9a-fA-F]+/); + return match ? match[0] : null; + } catch (e: any) { + if (e.stderr) console.error(String(e.stderr)); + return null; + } +} + +const contracts: string[] = JSON.parse(readFileSync(manifestPath, "utf-8")); +let errors = 0; + +for (const name of contracts) { + console.log(`::group::Checking ${name}`); + try { + const baseSol = join(baselineDir, "contracts", `${name}.sol`); + const prSol = join(prDir, "contracts", `${name}.sol`); + + if (!existsSync(baseSol)) { + console.log(`Skipping ${name} (new contract, not in baseline)`); + continue; + } + + if (!existsSync(prSol)) { + console.error(`::error::${name} listed in upgrade-manifest.json but missing in PR`); + errors++; + continue; + } + + const { versions: baseV } = extractVersions(baseSol); + const { versions: prV, source: prSrc } = extractVersions(prSol); + + let parseFailed = false; + for (const key of ["REINITIALIZER_VERSION", "MAJOR_VERSION", "MINOR_VERSION", "PATCH_VERSION"]) { + if (baseV[key] == null || prV[key] == null) { + console.error(`::error::Failed to parse ${key} for ${name}`); + errors++; + parseFailed = true; + } + } + if (parseFailed) continue; + + const prBytecode = forgeInspect(name, prDir); + if (prBytecode == null) { + console.error(`::error::Failed to compile ${name} on PR`); + errors++; + continue; + } + + const baseBytecode = forgeInspect(name, baselineDir); + if (baseBytecode == null) { + console.error(`::error::Failed to compile ${name} on baseline`); + errors++; + continue; + } + const bytecodeChanged = baseBytecode !== prBytecode; + const reinitChanged = baseV.REINITIALIZER_VERSION !== prV.REINITIALIZER_VERSION; + const versionChanged = + baseV.MAJOR_VERSION !== prV.MAJOR_VERSION || + baseV.MINOR_VERSION !== prV.MINOR_VERSION || + baseV.PATCH_VERSION !== prV.PATCH_VERSION; + + if (!bytecodeChanged) { + console.log(`${name}: bytecode unchanged`); + if (reinitChanged) { + console.error( + `::error::${name} REINITIALIZER_VERSION bumped (${baseV.REINITIALIZER_VERSION} -> ${prV.REINITIALIZER_VERSION}) but bytecode is unchanged`, + ); + errors++; + } + continue; + } + + console.log(`${name}: bytecode CHANGED`); + + if (!reinitChanged) { + console.error( + `::error::${name} bytecode changed but REINITIALIZER_VERSION was not bumped (still ${prV.REINITIALIZER_VERSION})`, + ); + errors++; + } else { + // Convention: reinitializeV{N-1} for REINITIALIZER_VERSION=N + const expectedFn = `reinitializeV${prV.REINITIALIZER_VERSION - 1}`; + const uncommented = prSrc.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); + if (!new RegExp(`function\\s+${expectedFn}\\s*\\(`).test(uncommented)) { + console.error( + `::error::${name} has REINITIALIZER_VERSION=${prV.REINITIALIZER_VERSION} but no ${expectedFn}() function found`, + ); + errors++; + } + } + + if (!versionChanged) { + console.error( + `::error::${name} bytecode changed but semantic version was not bumped (still v${prV.MAJOR_VERSION}.${prV.MINOR_VERSION}.${prV.PATCH_VERSION})`, + ); + errors++; + } + } finally { + console.log("::endgroup::"); + } +} + +if (errors > 0) { + console.error(`::error::Upgrade version check failed with ${errors} error(s)`); + process.exit(1); +} + +console.log("All contracts passed upgrade version checks"); diff --git a/ci/merge-address-constants.ts b/ci/merge-address-constants.ts new file mode 100644 index 0000000000..68c0518a8b --- /dev/null +++ b/ci/merge-address-constants.ts @@ -0,0 +1,147 @@ +#!/usr/bin/env bun +// +// Merges Solidity address-constant files from a baseline and a PR so that both +// sides can compile against the same unified set of constants. +// +// WHY THIS IS NEEDED +// ────────────────── +// We compare compiled bytecode between a baseline tag (last deployed release) +// and the PR to detect contract changes. Both sides must compile with +// *identical* address constants because those constants are embedded in +// bytecode — any difference would cause a false "bytecode changed" signal. +// +// The naive approach (generate addresses on the PR side, copy to baseline) +// breaks when contracts are added or removed between versions. For example, +// if the PR deletes MultichainACL, the generated addresses file no longer +// contains `multichainACLAddress`. But the baseline still has source files +// that import it, so forge compilation fails for the *entire* project — +// including unrelated contracts like GatewayConfig that haven't changed. +// +// The fix: generate addresses on BOTH sides, then merge. PR values win for +// shared constants (so both sides embed the same values). Constants that only +// exist in the baseline (removed contracts) are preserved so the baseline +// compiles. Constants that only exist in the PR (new contracts) are preserved +// so the PR compiles. The merged file is copied to both sides. +// +// USAGE +// bun ci/merge-address-constants.ts +// +// For each .sol file present in either directory, writes a merged version to +// BOTH directories. Exits 0 on success, 1 on error. + +import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs"; +import { join } from "path"; + +const ADDRESS_RE = /^address\s+constant\s+(\w+)\s*=\s*(0x[0-9a-fA-F]+)\s*;/; + +interface AddressConstant { + name: string; + value: string; + line: string; // original line for faithful reproduction +} + +/** + * Parse a Solidity address-constants file into its header (SPDX + pragma) and + * an ordered list of address constants. + */ +function parseAddressFile(content: string): { header: string; constants: AddressConstant[] } { + const lines = content.split("\n"); + const constants: AddressConstant[] = []; + const headerLines: string[] = []; + let inHeader = true; + + for (const line of lines) { + const match = line.match(ADDRESS_RE); + if (match) { + inHeader = false; + constants.push({ name: match[1], value: match[2], line }); + } else if (inHeader) { + headerLines.push(line); + } + // Skip blank lines between constants — we regenerate spacing + } + + return { header: headerLines.join("\n"), constants }; +} + +/** + * Merge two parsed address files. PR constants take precedence for shared + * names. Baseline-only constants are appended at the end. + */ +function mergeConstants( + baseline: AddressConstant[], + pr: AddressConstant[], +): AddressConstant[] { + const seen = new Set(); + const merged: AddressConstant[] = []; + + // PR constants first, in PR order — these values win for shared names + for (const c of pr) { + merged.push(c); + seen.add(c.name); + } + + // Baseline-only constants (removed in PR) — appended so baseline compiles + for (const c of baseline) { + if (!seen.has(c.name)) { + merged.push(c); + } + } + + return merged; +} + +/** + * Render merged constants back to a Solidity file. + */ +function renderAddressFile(header: string, constants: AddressConstant[]): string { + const lines = constants.map((c) => c.line); + return header.trimEnd() + "\n\n" + lines.join("\n") + "\n"; +} + +// --- Main --- + +const [baselineDir, prDir] = process.argv.slice(2); +if (!baselineDir || !prDir) { + console.error("Usage: bun ci/merge-address-constants.ts "); + process.exit(1); +} + +// Collect all .sol filenames from both directories +const baselineFiles = existsSync(baselineDir) + ? readdirSync(baselineDir).filter((f) => f.endsWith(".sol")) + : []; +const prFiles = existsSync(prDir) + ? readdirSync(prDir).filter((f) => f.endsWith(".sol")) + : []; +const allFiles = [...new Set([...baselineFiles, ...prFiles])]; + +for (const file of allFiles) { + const baselinePath = join(baselineDir, file); + const prPath = join(prDir, file); + + const hasBaseline = existsSync(baselinePath); + const hasPR = existsSync(prPath); + + if (hasBaseline && hasPR) { + // Merge: PR values win for shared constants, baseline-only constants preserved + const baselineParsed = parseAddressFile(readFileSync(baselinePath, "utf-8")); + const prParsed = parseAddressFile(readFileSync(prPath, "utf-8")); + const merged = mergeConstants(baselineParsed.constants, prParsed.constants); + const output = renderAddressFile(prParsed.header, merged); + + console.log(`${file}: merged (${prParsed.constants.length} PR + ${baselineParsed.constants.length} baseline → ${merged.length} total)`); + writeFileSync(baselinePath, output); + writeFileSync(prPath, output); + } else if (hasBaseline) { + // File only in baseline (removed in PR) — copy to PR so baseline imports resolve + console.log(`${file}: baseline-only, copying to PR`); + writeFileSync(prPath, readFileSync(baselinePath, "utf-8")); + } else { + // File only in PR (new) — copy to baseline so PR imports resolve + console.log(`${file}: PR-only, copying to baseline`); + writeFileSync(baselinePath, readFileSync(prPath, "utf-8")); + } +} + +console.log("Address constants merged successfully"); diff --git a/gateway-contracts/foundry.toml b/gateway-contracts/foundry.toml index c77611f5d9..f8c901533d 100644 --- a/gateway-contracts/foundry.toml +++ b/gateway-contracts/foundry.toml @@ -2,3 +2,10 @@ src = 'contracts' libs = ['node_modules'] solc = '0.8.24' +cbor_metadata = false +bytecode_hash = 'none' + +remappings = [ + '@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/', + '@openzeppelin/contracts-upgradeable/=node_modules/@openzeppelin/contracts-upgradeable/', +] diff --git a/host-contracts/foundry.toml b/host-contracts/foundry.toml index 8e95a13d12..79268ce7f9 100644 --- a/host-contracts/foundry.toml +++ b/host-contracts/foundry.toml @@ -5,6 +5,8 @@ libs = ['node_modules', 'lib', "dependencies"] test = 'test' cache_path = 'cache_forge' solc = '0.8.24' +cbor_metadata = false +bytecode_hash = 'none' remappings = [ '@fhevm-foundry/=./fhevm-foundry/',