diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..20bf558b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +# Dependencies (will be installed fresh in container) +node_modules/ + +# Build artifacts (will be built fresh in container) +out/ +cache_forge/ +cache/ +artifacts/ +typechain-types/ + +# Environment files (contain secrets) +.env +.env.* +!.env.example + +# Git +.git/ +.gitignore + +# IDE +.vscode/ +.idea/ + +# Test artifacts +broadcast/ +coverage/ + +# Docker +Dockerfile +.dockerignore diff --git a/.eslintignore b/.eslintignore index c3af8579..a89c6a71 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ lib/ +dist/ diff --git a/.eslintrc.js b/.eslintrc.js index 23d5ab1a..cd3e7f3d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -60,7 +60,7 @@ module.exports = { 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', ], - plugins: ['@typescript-eslint', 'prettier', '@typescript-eslint/tslint'], + plugins: ['@typescript-eslint', 'prettier'], rules: { 'no-empty-pattern': 'warn', 'prettier/prettier': ['error', { singleQuote: true }], @@ -77,12 +77,6 @@ module.exports = { caughtErrorsIgnorePattern: '^_', }, ], - '@typescript-eslint/tslint/config': [ - 'error', - { - rules: { 'strict-comparisons': true }, - }, - ], 'no-implicit-coercion': 'error', '@typescript-eslint/no-shadow': ['error'], }, diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml new file mode 100644 index 00000000..d5e2d674 --- /dev/null +++ b/.github/workflows/publish-docker.yml @@ -0,0 +1,53 @@ +name: Publish Docker + +on: + push: + branches: [main] + tags: ['v*'] + workflow_dispatch: + +jobs: + publish: + name: Build and Push Docker Image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: stable + + - name: Install forge dependencies + run: forge install + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: offchainlabs/chain-actions + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=branch + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml new file mode 100644 index 00000000..04a7f78a --- /dev/null +++ b/.github/workflows/test-docker.yml @@ -0,0 +1,38 @@ +name: Test Docker + +on: + pull_request: + workflow_dispatch: + +jobs: + test-docker: + name: Test Docker Image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: stable + + - name: Install forge dependencies + run: forge install + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + load: true + tags: orbit-actions:test + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run Docker smoke tests + run: ./test/docker/test-docker.bash + env: + DOCKER_IMAGE: orbit-actions:test diff --git a/.gitignore b/.gitignore index 8e35eb36..e00e0e6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ node_modules .env +.DS_Store + +# TypeScript build output +/dist # Hardhat files /cache diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..864e3ada --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM node:18-slim + +# Install dependencies for Foundry, git, and jq (for JSON parsing in upgrade scripts) +RUN apt-get update && apt-get install -y \ + curl \ + git \ + jq \ + && rm -rf /var/lib/apt/lists/* + +# Install Foundry +ENV PATH="/root/.foundry/bin:${PATH}" +RUN curl -L https://foundry.paradigm.xyz | bash && foundryup + +# Install Yarn Classic (v1) - matches the repo's yarn.lock format +RUN npm install -g --force yarn@1.22.22 + +WORKDIR /app + +# Copy package files first for better layer caching +COPY package.json yarn.lock ./ + +# --ignore-scripts: forge install runs separately after full copy +RUN yarn install --frozen-lockfile --ignore-scripts + +COPY . . +RUN forge build +RUN yarn build:cli + +ENTRYPOINT ["node", "/app/dist/cli/index.js"] diff --git a/README.md b/README.md index bf5fe027..384fad55 100644 --- a/README.md +++ b/README.md @@ -132,4 +132,50 @@ See [setCacheManager](scripts/foundry/stylus/setCacheManager). Currently limited to L2s; L3 support is expected in a future update. -See [Nitro contracts 3.1.0 upgrade](https://github.com/OffchainLabs/orbit-actions/tree/main/scripts/foundry/contract-upgrades/3.1.0). +See [Nitro contracts 3.1.0 upgrade](https://github.com/OffchainLabs/orbit-actions/tree/main/scripts/foundry/contract-upgrades/3.1.0). + +# CLI + +The `orbit-actions` CLI provides a guided interface for running upgrade scripts. It wraps Foundry commands and handles the deploy/execute/verify workflow. + +```bash +# Browse available scripts +yarn orbit-actions # List top-level directories +yarn orbit-actions contract-upgrades # List versions +yarn orbit-actions contract-upgrades/1.2.1 # List contents + commands + +# View files +yarn orbit-actions contract-upgrades/1.2.1/README.md + +# Run contract upgrades +yarn orbit-actions contract-upgrades/1.2.1/deploy-execute-verify --dry-run +yarn orbit-actions contract-upgrades/1.2.1/deploy --private-key $KEY + +# Run ArbOS upgrades +yarn orbit-actions arbos-upgrades/at-timestamp/deploy-execute-verify 32 --dry-run +``` + +Run `yarn orbit-actions help` for full usage details. The CLI reads configuration from a `.env` file in the working directory. + +## Docker + +The CLI is available as a Docker image at `offchainlabs/orbit-actions`: + +```bash +# Check contract versions +docker run --rm \ + -e INBOX_ADDRESS=0xaE21fDA3de92dE2FDAF606233b2863782Ba046F9 \ + -e INFURA_KEY=$INFURA_KEY \ + offchainlabs/orbit-actions:versioner \ + --network arb1 + +# Browse upgrade scripts +docker run --rm offchainlabs/orbit-actions contract-upgrades + +# Run upgrade with env file and capture broadcast output +docker run --rm \ + -v $(pwd)/.env:/app/.env \ + -v $(pwd)/broadcast:/app/broadcast \ + offchainlabs/orbit-actions \ + contract-upgrades/1.2.1/deploy-execute-verify --dry-run +``` diff --git a/package.json b/package.json index 74e3f3ba..69e020fa 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,12 @@ "version": "1.0.0", "repository": "https://github.com/OffchainLabs/blockchain-eng-template.git", "license": "Apache 2.0", + "bin": { + "orbit-actions": "./dist/cli/index.js" + }, "scripts": { + "build:cli": "tsc -p tsconfig.cli.json", + "cli": "ts-node src/cli/index.ts", "prepare": "forge install && cd lib/arbitrum-sdk && yarn", "minimal-publish": "./scripts/publish.bash", "minimal-install": "yarn --ignore-scripts && forge install", @@ -20,6 +25,7 @@ "test:gas-check": "forge snapshot --check --tolerance 1 --match-path \"test/unit/**/*.t.sol\"", "test:sigs": "./test/signatures/test-sigs.bash", "test:storage": "./test/storage/test-storage.bash", + "test:docker": "./test/docker/test-docker.bash", "orbit:contracts:version": "hardhat run scripts/orbit-versioner/orbitVersioner.ts", "gas-snapshot": "forge snapshot --match-path \"test/unit/**/*.t.sol\"", "fix": "yarn format; yarn test:sigs; yarn test:storage; yarn gas-snapshot" @@ -61,5 +67,9 @@ "ts-node": ">=8.0.0", "typechain": "^8.3.0", "typescript": ">=4.5.0" + }, + "dependencies": { + "commander": "^12.0.0", + "execa": "^5.1.1" } } diff --git a/scripts/foundry/contract-upgrades/1.2.1/README.md b/scripts/foundry/contract-upgrades/1.2.1/README.md index 4c9b75c2..78d7a63b 100644 --- a/scripts/foundry/contract-upgrades/1.2.1/README.md +++ b/scripts/foundry/contract-upgrades/1.2.1/README.md @@ -57,7 +57,7 @@ forge script --sender $EXECUTOR --rpc-url $PARENT_CHAIN_RPC --broadcast ./Execut ``` If you have a multisig as executor, you can still run the above command without broadcasting to get the payload for the multisig transaction. -4. That's it, upgrade has been performed. You can make sure it has successfully executed by checking wasm module root: +4. That's it, upgrade has been performed. You can verify by running: ```bash -cast call --rpc-url $PARENT_CHAIN_RPC $ROLLUP "wasmModuleRoot()" +forge script --rpc-url $PARENT_CHAIN_RPC VerifyNitroContracts1Point2Point1Upgrade -vvv ``` diff --git a/scripts/foundry/contract-upgrades/1.2.1/VerifyNitroContracts1Point2Point1Upgrade.s.sol b/scripts/foundry/contract-upgrades/1.2.1/VerifyNitroContracts1Point2Point1Upgrade.s.sol new file mode 100644 index 00000000..00457948 --- /dev/null +++ b/scripts/foundry/contract-upgrades/1.2.1/VerifyNitroContracts1Point2Point1Upgrade.s.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.16; + +import "forge-std/Script.sol"; + +interface IRollupCore { + function wasmModuleRoot() external view returns (bytes32); +} + +/** + * @title VerifyNitroContracts1Point2Point1Upgrade + * @notice Verifies the upgrade to Nitro Contracts 1.2.1 by checking the wasmModuleRoot + */ +contract VerifyNitroContracts1Point2Point1Upgrade is Script { + function run() public view { + address rollup = vm.envAddress("ROLLUP"); + bytes32 wasmRoot = IRollupCore(rollup).wasmModuleRoot(); + console.log("wasmModuleRoot:"); + console.logBytes32(wasmRoot); + } +} diff --git a/scripts/foundry/contract-upgrades/2.1.0/README.md b/scripts/foundry/contract-upgrades/2.1.0/README.md index 99c055f5..1762eabe 100644 --- a/scripts/foundry/contract-upgrades/2.1.0/README.md +++ b/scripts/foundry/contract-upgrades/2.1.0/README.md @@ -68,10 +68,10 @@ forge script --sender $EXECUTOR --rpc-url $PARENT_CHAIN_RPC --broadcast ExecuteN If you have a multisig as executor, you can still run the above command without broadcasting to get the payload for the multisig transaction. -4. That's it, upgrade has been performed. You can make sure it has successfully executed by checking wasm module root: +4. That's it, upgrade has been performed. You can verify by running: ```bash -cast call --rpc-url $PARENT_CHAIN_RPC $ROLLUP "wasmModuleRoot()" +forge script --rpc-url $PARENT_CHAIN_RPC VerifyNitroContracts2Point1Point0Upgrade -vvv ``` ## FAQ diff --git a/scripts/foundry/contract-upgrades/2.1.0/VerifyNitroContracts2Point1Point0Upgrade.s.sol b/scripts/foundry/contract-upgrades/2.1.0/VerifyNitroContracts2Point1Point0Upgrade.s.sol new file mode 100644 index 00000000..4f2a8a63 --- /dev/null +++ b/scripts/foundry/contract-upgrades/2.1.0/VerifyNitroContracts2Point1Point0Upgrade.s.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.16; + +import "forge-std/Script.sol"; + +interface IRollupCore { + function wasmModuleRoot() external view returns (bytes32); +} + +/** + * @title VerifyNitroContracts2Point1Point0Upgrade + * @notice Verifies the upgrade to Nitro Contracts 2.1.0 by checking the wasmModuleRoot + */ +contract VerifyNitroContracts2Point1Point0Upgrade is Script { + function run() public view { + address rollup = vm.envAddress("ROLLUP"); + bytes32 wasmRoot = IRollupCore(rollup).wasmModuleRoot(); + console.log("wasmModuleRoot:"); + console.logBytes32(wasmRoot); + } +} diff --git a/scripts/foundry/contract-upgrades/2.1.2/README.md b/scripts/foundry/contract-upgrades/2.1.2/README.md index 16b04a6c..9d16797f 100644 --- a/scripts/foundry/contract-upgrades/2.1.2/README.md +++ b/scripts/foundry/contract-upgrades/2.1.2/README.md @@ -73,11 +73,10 @@ forge script --sender $EXECUTOR --rpc-url $PARENT_CHAIN_RPC --broadcast ExecuteN If you have a multisig as executor, you can still run the above command without broadcasting to get the payload for the multisig transaction. -4. That's it, upgrade has been performed. You can make sure it has successfully executed by checking the native token decimals. +4. That's it, upgrade has been performed. You can verify by running: ```bash -# should return 18 -cast call --rpc-url $PARENT_CHAIN_RPC $BRIDGE "nativeTokenDecimals()(uint8)" +forge script --rpc-url $PARENT_CHAIN_RPC VerifyNitroContracts2Point1Point2Upgrade -vvv ``` ## FAQ diff --git a/scripts/foundry/contract-upgrades/2.1.2/VerifyNitroContracts2Point1Point2Upgrade.s.sol b/scripts/foundry/contract-upgrades/2.1.2/VerifyNitroContracts2Point1Point2Upgrade.s.sol new file mode 100644 index 00000000..4e7b1691 --- /dev/null +++ b/scripts/foundry/contract-upgrades/2.1.2/VerifyNitroContracts2Point1Point2Upgrade.s.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.16; + +import "forge-std/Script.sol"; + +interface IInbox { + function bridge() external view returns (address); +} + +interface IBridge { + function nativeTokenDecimals() external view returns (uint8); +} + +/** + * @title VerifyNitroContracts2Point1Point2Upgrade + * @notice Verifies the upgrade to Nitro Contracts 2.1.2 by checking nativeTokenDecimals + */ +contract VerifyNitroContracts2Point1Point2Upgrade is Script { + function run() public view { + address inbox = vm.envAddress("INBOX_ADDRESS"); + address bridge = IInbox(inbox).bridge(); + uint8 decimals = IBridge(bridge).nativeTokenDecimals(); + console.log("nativeTokenDecimals:", decimals); + } +} diff --git a/scripts/foundry/contract-upgrades/2.1.3/README.md b/scripts/foundry/contract-upgrades/2.1.3/README.md index af8e4087..adff9181 100644 --- a/scripts/foundry/contract-upgrades/2.1.3/README.md +++ b/scripts/foundry/contract-upgrades/2.1.3/README.md @@ -72,11 +72,10 @@ forge script --sender $EXECUTOR --rpc-url $PARENT_CHAIN_RPC --broadcast ExecuteN If you have a multisig as executor, you can still run the above command without broadcasting to get the payload for the multisig transaction. -4. That's it, upgrade has been performed. You can make sure it has successfully executed by checking the native token decimals. +4. That's it, upgrade has been performed. You can verify by running: ```bash -# should return 18 -cast call --rpc-url $PARENT_CHAIN_RPC $BRIDGE "nativeTokenDecimals()(uint8)" +forge script --rpc-url $PARENT_CHAIN_RPC VerifyNitroContracts2Point1Point3Upgrade -vvv ``` ## FAQ diff --git a/scripts/foundry/contract-upgrades/2.1.3/VerifyNitroContracts2Point1Point3Upgrade.s.sol b/scripts/foundry/contract-upgrades/2.1.3/VerifyNitroContracts2Point1Point3Upgrade.s.sol new file mode 100644 index 00000000..353130df --- /dev/null +++ b/scripts/foundry/contract-upgrades/2.1.3/VerifyNitroContracts2Point1Point3Upgrade.s.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.16; + +import "forge-std/Script.sol"; + +interface IInbox { + function bridge() external view returns (address); +} + +interface IBridge { + function nativeTokenDecimals() external view returns (uint8); +} + +/** + * @title VerifyNitroContracts2Point1Point3Upgrade + * @notice Verifies the upgrade to Nitro Contracts 2.1.3 by checking nativeTokenDecimals + */ +contract VerifyNitroContracts2Point1Point3Upgrade is Script { + function run() public view { + address inbox = vm.envAddress("INBOX_ADDRESS"); + address bridge = IInbox(inbox).bridge(); + uint8 decimals = IBridge(bridge).nativeTokenDecimals(); + console.log("nativeTokenDecimals:", decimals); + } +} diff --git a/src/cli/commands/arbos-upgrade.ts b/src/cli/commands/arbos-upgrade.ts new file mode 100644 index 00000000..0a598485 --- /dev/null +++ b/src/cli/commands/arbos-upgrade.ts @@ -0,0 +1,243 @@ +import * as path from 'path' +import * as fs from 'fs' +import { log, die } from '../utils/log' +import { requireEnv, getEnv, getScriptsDir } from '../utils/env' +import { + parseAuthArgs, + createDeployExecuteAuth, + getDeployAuth, + getExecuteAuth, +} from '../utils/auth' +import { + runForgeScript, + runCastSend, + runCastCall, + castCalldata, + getChainId, + parseActionAddress, +} from '../utils/forge' + +const ARBOS_DIR = path.join(getScriptsDir(), 'arbos-upgrades', 'at-timestamp') +const DEPLOY_SCRIPT = path.join( + ARBOS_DIR, + 'DeployUpgradeArbOSVersionAtTimestampAction.s.sol' +) + +// ArbOS precompile addresses +const ARB_OWNER_PUBLIC = '0x000000000000000000000000000000000000006b' +const ARB_SYS = '0x0000000000000000000000000000000000000064' + +// Nitro ArbOS versions are offset by 55 to avoid collision with classic (pre-Nitro) versions +const ARBOS_VERSION_OFFSET = 55 +const PERFORM_SELECTOR = '0xb0a75d36' + +function checkDeployScript(): void { + if (!fs.existsSync(DEPLOY_SCRIPT)) { + die(`Deploy script not found: ${DEPLOY_SCRIPT}`) + } +} + +async function deployAction( + version: string, + rpcUrl: string, + authArgs: string, + options: { broadcast: boolean; verify?: boolean } +): Promise { + checkDeployScript() + process.env.ARBOS_VERSION = version + + await runForgeScript({ + script: DEPLOY_SCRIPT, + rpcUrl, + authArgs, + broadcast: options.broadcast, + verify: options.verify, + slow: true, + }) +} + +async function executeUpgrade( + actionAddress: string, + upgradeExecutor: string, + rpcUrl: string, + authArgs: string, + dryRun: boolean +): Promise { + if (dryRun || !authArgs) { + const executeCalldata = await castCalldata( + 'execute(address,bytes)', + actionAddress, + PERFORM_SELECTOR + ) + + log( + dryRun + ? 'Dry run - calldata for UpgradeExecutor.execute():' + : 'Calldata for UpgradeExecutor.execute():' + ) + console.log('') + console.log(`To: ${upgradeExecutor}`) + console.log(`Calldata: ${executeCalldata}`) + console.log('') + log('Submit this to your multisig/Safe to execute the upgrade') + } else { + await runCastSend({ + to: upgradeExecutor, + sig: 'execute(address,bytes)', + args: [actionAddress, PERFORM_SELECTOR], + rpcUrl, + authArgs, + }) + + log('ArbOS upgrade scheduled successfully') + } +} + +async function verifyUpgrade(rpcUrl: string): Promise { + log('Checking ArbOS upgrade status...') + + const scheduled = await runCastCall({ + to: ARB_OWNER_PUBLIC, + sig: 'getScheduledUpgrade()(uint64,uint64)', + rpcUrl, + }) + log(`Scheduled upgrade (version, timestamp): ${scheduled}`) + + const currentRaw = await runCastCall({ + to: ARB_SYS, + sig: 'arbOSVersion()(uint64)', + rpcUrl, + }) + + let currentVersion: number + if (currentRaw === 'N/A') { + currentVersion = 0 + } else { + const rawNum = parseInt(currentRaw, 10) + currentVersion = rawNum - ARBOS_VERSION_OFFSET + } + + log(`Current ArbOS version: ${currentVersion}`) +} + +async function cmdDeploy(version: string, args: string[]): Promise { + const authArgs = parseAuthArgs(args) + const rpcUrl = requireEnv('CHILD_CHAIN_RPC') + log(`Running: ${path.basename(DEPLOY_SCRIPT)} for ArbOS ${version}`) + await deployAction(version, rpcUrl, authArgs, { + broadcast: Boolean(authArgs), + }) +} + +async function cmdExecute(args: string[]): Promise { + const authArgs = parseAuthArgs(args) + + const rpcUrl = requireEnv('CHILD_CHAIN_RPC') + const upgradeExecutor = requireEnv('CHILD_UPGRADE_EXECUTOR_ADDRESS') + const actionAddress = requireEnv('UPGRADE_ACTION_ADDRESS') + + log(`Executing ArbOS upgrade action: ${actionAddress}`) + + await executeUpgrade(actionAddress, upgradeExecutor, rpcUrl, authArgs, false) +} + +async function cmdVerify(): Promise { + const rpcUrl = requireEnv('CHILD_CHAIN_RPC') + await verifyUpgrade(rpcUrl) +} + +async function cmdDeployExecuteVerify( + version: string, + options: { + deployKey?: string + deployAccount?: string + deployLedger?: boolean + deployInteractive?: boolean + executeKey?: string + executeAccount?: string + executeLedger?: boolean + executeInteractive?: boolean + dryRun?: boolean + skipExecute?: boolean + verify?: boolean + } +): Promise { + const auth = createDeployExecuteAuth(options) + + log(`ArbOS version: ${version}`) + checkDeployScript() + + const skipDeploy = Boolean(getEnv('UPGRADE_ACTION_ADDRESS')) + let upgradeActionAddress = getEnv('UPGRADE_ACTION_ADDRESS') || '' + + if (skipDeploy) { + log(`Using existing action from .env: ${upgradeActionAddress}`) + } + + const rpcUrl = requireEnv('CHILD_CHAIN_RPC') + const upgradeExecutor = requireEnv('CHILD_UPGRADE_EXECUTOR_ADDRESS') + requireEnv('SCHEDULE_TIMESTAMP') + + const deployAuth = getDeployAuth(auth) + const executeAuth = getExecuteAuth(auth) + + if (!skipDeploy && !auth.dryRun && !deployAuth) { + die( + 'Deploy auth required. Use --deploy-key, --deploy-account, --deploy-ledger, or --deploy-interactive' + ) + } + if (!auth.skipExecute && !auth.dryRun && !executeAuth) { + die( + 'Execute auth required. Use --execute-key, --execute-account, --execute-ledger, or --execute-interactive' + ) + } + + log(`Scheduled timestamp: ${process.env.SCHEDULE_TIMESTAMP}`) + + let chainId = '' + if (!skipDeploy) { + chainId = await getChainId(rpcUrl) + log(`Target chain ID: ${chainId}`) + log('Step 1: Deploying ArbOS upgrade action...') + + await deployAction(version, rpcUrl, deployAuth, { + broadcast: !auth.dryRun, + verify: auth.verifyContracts, + }) + + if (!auth.dryRun) { + upgradeActionAddress = parseActionAddress(DEPLOY_SCRIPT, chainId) + log(`Deployed action at: ${upgradeActionAddress}`) + } else { + log('Dry run - no action deployed') + if (!auth.skipExecute) { + log('Note: Set UPGRADE_ACTION_ADDRESS in .env to run execute step') + return + } + } + } else { + log('Step 1: Skipped deploy') + } + + if (!auth.skipExecute) { + log('Step 2: Executing ArbOS upgrade...') + await executeUpgrade( + upgradeActionAddress, + upgradeExecutor, + rpcUrl, + executeAuth, + auth.dryRun + ) + } else { + log('Step 2: Skipped execute') + } + + if (!auth.dryRun && !auth.skipExecute) { + log('Step 3: Verifying scheduled upgrade...') + await verifyUpgrade(rpcUrl) + } + + log('Done') +} + +export { cmdDeploy, cmdExecute, cmdVerify, cmdDeployExecuteVerify } diff --git a/src/cli/commands/contract-upgrade.ts b/src/cli/commands/contract-upgrade.ts new file mode 100644 index 00000000..48444311 --- /dev/null +++ b/src/cli/commands/contract-upgrade.ts @@ -0,0 +1,234 @@ +import * as path from 'path' +import * as fs from 'fs' +import { log, die } from '../utils/log' +import { requireEnv, getEnv, getScriptsDir } from '../utils/env' +import { + parseAuthArgs, + createDeployExecuteAuth, + getDeployAuth, + getExecuteAuth, +} from '../utils/auth' +import { + runForgeScript, + getChainId, + parseActionAddress, + findScript, +} from '../utils/forge' + +const CONTRACTS_DIR = path.join(getScriptsDir(), 'contract-upgrades') + +function getVersionDir(version: string): string { + const versionDir = path.join(CONTRACTS_DIR, version) + if (!fs.existsSync(versionDir)) { + const available = fs.existsSync(CONTRACTS_DIR) + ? fs + .readdirSync(CONTRACTS_DIR) + .filter(f => !f.startsWith('.')) + .join(' ') + : 'none found' + die(`Unknown version: ${version}\n\nAvailable versions: ${available}`) + } + return versionDir +} + +async function cmdDeploy(version: string, args: string[]): Promise { + const versionDir = getVersionDir(version) + const authArgs = parseAuthArgs(args) + + const rpcUrl = requireEnv('PARENT_CHAIN_RPC') + + const deployScript = findScript(versionDir, /^Deploy.*\.s\.sol$/) + if (!deployScript) { + die(`No deploy script found in ${versionDir}`) + } + + log(`Running: ${path.basename(deployScript)}`) + + await runForgeScript({ + script: deployScript, + rpcUrl, + authArgs, + broadcast: Boolean(authArgs), + slow: true, + skipSimulation: true, + }) +} + +async function cmdExecute(version: string, args: string[]): Promise { + const versionDir = getVersionDir(version) + const authArgs = parseAuthArgs(args) + + const rpcUrl = requireEnv('PARENT_CHAIN_RPC') + requireEnv('UPGRADE_ACTION_ADDRESS') + + const executeScript = findScript(versionDir, /^Execute.*\.s\.sol$/) + if (!executeScript) { + die(`No execute script found in ${versionDir}`) + } + + log(`Running: ${path.basename(executeScript)}`) + + await runForgeScript({ + script: executeScript, + rpcUrl, + authArgs, + broadcast: Boolean(authArgs), + }) +} + +async function cmdVerify(version: string): Promise { + const versionDir = getVersionDir(version) + + const rpcUrl = requireEnv('PARENT_CHAIN_RPC') + + const verifyScript = findScript(versionDir, /^Verify.*\.s\.sol$/) + if (!verifyScript) { + die( + `No verify script found in ${versionDir} - check README for manual verification` + ) + } + + log(`Running: ${path.basename(verifyScript)}`) + + await runForgeScript({ + script: verifyScript, + rpcUrl, + }) +} + +async function cmdDeployExecuteVerify( + version: string, + options: { + deployKey?: string + deployAccount?: string + deployLedger?: boolean + deployInteractive?: boolean + executeKey?: string + executeAccount?: string + executeLedger?: boolean + executeInteractive?: boolean + dryRun?: boolean + skipExecute?: boolean + verify?: boolean + } +): Promise { + const versionDir = getVersionDir(version) + const auth = createDeployExecuteAuth(options) + + const deployScript = findScript(versionDir, /^Deploy.*\.s\.sol$/) + const executeScript = findScript(versionDir, /^Execute.*\.s\.sol$/) + + if (!deployScript) { + die(`No deploy script found in ${versionDir}`) + } + if (!executeScript) { + die(`No execute script found in ${versionDir}`) + } + + log(`Version: ${version}`) + log(`Deploy script: ${path.basename(deployScript)}`) + log(`Execute script: ${path.basename(executeScript)}`) + + // Auto-detect skip_deploy if UPGRADE_ACTION_ADDRESS is set + const skipDeploy = Boolean(getEnv('UPGRADE_ACTION_ADDRESS')) + let upgradeActionAddress = getEnv('UPGRADE_ACTION_ADDRESS') || '' + + if (skipDeploy) { + log(`Using existing action from .env: ${upgradeActionAddress}`) + } + + const rpcUrl = requireEnv('PARENT_CHAIN_RPC') + requireEnv('INBOX_ADDRESS') + requireEnv('PROXY_ADMIN_ADDRESS') + requireEnv('PARENT_UPGRADE_EXECUTOR_ADDRESS') + + const deployAuth = getDeployAuth(auth) + const executeAuth = getExecuteAuth(auth) + + if (!skipDeploy && !auth.dryRun && !deployAuth) { + die( + 'Deploy auth required. Use --deploy-key, --deploy-account, --deploy-ledger, or --deploy-interactive' + ) + } + if (!auth.skipExecute && !auth.dryRun && !executeAuth) { + die( + 'Execute auth required. Use --execute-key, --execute-account, --execute-ledger, or --execute-interactive' + ) + } + + const chainId = await getChainId(rpcUrl) + log(`Target chain ID: ${chainId}`) + + if (!skipDeploy) { + log('Step 1: Deploying upgrade action...') + + await runForgeScript({ + script: deployScript, + rpcUrl, + authArgs: deployAuth, + broadcast: !auth.dryRun, + verify: auth.verifyContracts, + slow: true, + skipSimulation: true, + }) + + if (!auth.dryRun) { + upgradeActionAddress = parseActionAddress(deployScript, chainId) + log(`Deployed action at: ${upgradeActionAddress}`) + } else { + log('Dry run - no action deployed') + if (!auth.skipExecute) { + log('Note: Set UPGRADE_ACTION_ADDRESS in .env to run execute step') + return + } + } + } else { + log('Step 1: Skipped deploy') + } + + // Forge script reads this from env + process.env.UPGRADE_ACTION_ADDRESS = upgradeActionAddress + + if (!auth.skipExecute) { + log('Step 2: Executing upgrade...') + + await runForgeScript({ + script: executeScript, + rpcUrl, + authArgs: executeAuth, + broadcast: !auth.dryRun, + }) + + if (auth.dryRun) { + log('Dry run - upgrade not executed') + } else { + log('Upgrade executed successfully') + } + } else { + log('Step 2: Skipped execute') + } + + if (!auth.dryRun && !auth.skipExecute) { + log('Step 3: Verifying upgrade...') + + const verifyScript = findScript(versionDir, /^Verify.*\.s\.sol$/) + if (verifyScript) { + await runForgeScript({ + script: verifyScript, + rpcUrl, + }) + } else { + log('No Verify script found - check README for manual verification') + } + } + + log('Done') +} + +export { + cmdDeploy, + cmdExecute, + cmdVerify, + cmdDeployExecuteVerify, + getVersionDir, +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 00000000..6d5ee588 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +import { program } from 'commander' +import { loadEnv } from './utils/env' +import { router } from './router' + +loadEnv() + +program + .name('orbit-actions') + .description('CLI for Orbit chain upgrade actions') + .argument('[path]', 'Path to browse or command to run') + .argument('[args...]', 'Additional arguments') + .allowUnknownOption(true) + .action(async (pathArg?: string, args?: string[]) => { + await router(pathArg, args) + }) + +program.parse() diff --git a/src/cli/router.ts b/src/cli/router.ts new file mode 100644 index 00000000..606e9821 --- /dev/null +++ b/src/cli/router.ts @@ -0,0 +1,220 @@ +import * as fs from 'fs' +import * as path from 'path' +import { die } from './utils/log' +import { getScriptsDir } from './utils/env' +import { + cmdDeploy as contractDeploy, + cmdExecute as contractExecute, + cmdVerify as contractVerify, + cmdDeployExecuteVerify as contractDeployExecuteVerify, +} from './commands/contract-upgrade' +import { + cmdDeploy as arbosDeploy, + cmdExecute as arbosExecute, + cmdVerify as arbosVerify, + cmdDeployExecuteVerify as arbosDeployExecuteVerify, +} from './commands/arbos-upgrade' + +const HELP_TEXT = `Usage: orbit-actions [path] [args...] + +Browse and execute scripts from the foundry scripts directory. + +Browsing: + . List top-level directories + contract-upgrades List available versions + contract-upgrades/1.2.1 List version contents + commands + contract-upgrades/1.2.1/env-templates List env templates + +Viewing files: + contract-upgrades/1.2.1/README.md View README + contract-upgrades/1.2.1/env-templates/.env.example View env template + contract-upgrades/2.1.0/.env.sample View env sample + +Running upgrade scripts: + contract-upgrades//deploy [--private-key KEY] + contract-upgrades//execute [--private-key KEY] + contract-upgrades//verify + contract-upgrades//deploy-execute-verify [--deploy-key KEY] [--execute-key KEY] + + arbos-upgrades/at-timestamp/deploy [--private-key KEY] + arbos-upgrades/at-timestamp/execute [--private-key KEY] + arbos-upgrades/at-timestamp/verify + arbos-upgrades/at-timestamp/deploy-execute-verify [--deploy-key KEY] [--execute-key KEY] + +Options for deploy-execute-verify: + --deploy-key KEY Private key for deploy step + --deploy-account NAME Keystore account for deploy + --deploy-ledger Use Ledger for deploy + --execute-key KEY Private key for execute step + --execute-account NAME Keystore account for execute + --execute-ledger Use Ledger for execute + --dry-run, -n Simulate without broadcasting + --skip-execute Deploy only + --verify, -v Verify on block explorer + +Examples: + docker run orbit-actions contract-upgrades/1.2.1 + docker run orbit-actions contract-upgrades/1.2.1/README.md + docker run -v $(pwd)/.env:/app/.env orbit-actions contract-upgrades/1.2.1/deploy-execute-verify --dry-run` + +function listDirectory(dir: string): void { + const scriptsDir = getScriptsDir() + let relPath = path.relative(scriptsDir, dir) + if (relPath === '.') relPath = '' + + const contents = fs.readdirSync(dir) + for (const item of contents) { + if (!item.startsWith('.')) { + console.log(item) + } + } + + if (/^contract-upgrades\/[0-9]/.test(relPath)) { + console.log('---') + console.log('deploy (run Deploy script)') + console.log('execute (run Execute script)') + console.log('verify (run Verify script)') + console.log('deploy-execute-verify (full upgrade flow)') + } else if (relPath === 'arbos-upgrades/at-timestamp') { + console.log('---') + console.log('deploy (run Deploy script)') + console.log('execute (execute upgrade action)') + console.log('verify (check upgrade status)') + console.log('deploy-execute-verify (full upgrade flow)') + } +} + +function parseOptions(args: string[]): Record { + const options: Record = {} + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if ( + arg === '--deploy-key' || + arg === '--execute-key' || + arg === '--deploy-account' || + arg === '--execute-account' + ) { + options[ + arg.replace(/^--/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase()) + ] = args[++i] || '' + } else if (arg === '--deploy-ledger') { + options.deployLedger = true + } else if (arg === '--execute-ledger') { + options.executeLedger = true + } else if (arg === '--deploy-interactive') { + options.deployInteractive = true + } else if (arg === '--execute-interactive') { + options.executeInteractive = true + } else if (arg === '--dry-run' || arg === '-n') { + options.dryRun = true + } else if (arg === '--skip-execute') { + options.skipExecute = true + } else if (arg === '--verify' || arg === '-v') { + options.verify = true + } else if (arg === '--private-key') { + options.privateKey = args[++i] || '' + } else if (arg === '--account') { + options.account = args[++i] || '' + } else if (arg === '--ledger') { + options.ledger = true + } else if (arg === '--interactive') { + options.interactive = true + } + } + return options +} + +export async function router( + pathArg?: string, + args: string[] = [] +): Promise { + const scriptsDir = getScriptsDir() + + if (!pathArg) { + const contents = fs.readdirSync(scriptsDir) + for (const item of contents) { + if (!item.startsWith('.')) { + console.log(item) + } + } + return + } + + if (pathArg === 'help' || pathArg === '--help' || pathArg === '-h') { + console.log(HELP_TEXT) + return + } + + const fullPath = path.join(scriptsDir, pathArg) + const parentPath = path.dirname(fullPath) + const basename = path.basename(pathArg) + + if (!fs.existsSync(fullPath) && fs.existsSync(parentPath)) { + const relParent = path.relative(scriptsDir, parentPath) + + if (/^contract-upgrades\/[0-9]/.test(relParent)) { + const version = path.basename(relParent) + const options = parseOptions(args) + + switch (basename) { + case 'deploy': + await contractDeploy(version, args) + return + case 'execute': + await contractExecute(version, args) + return + case 'verify': + await contractVerify(version) + return + case 'deploy-execute-verify': + await contractDeployExecuteVerify(version, options) + return + } + } + + if (relParent === 'arbos-upgrades/at-timestamp') { + switch (basename) { + case 'deploy': + case 'deploy-execute-verify': { + const version = args[0] + if (!version) { + console.error(`Error: ArbOS version required`) + console.error( + `Usage: arbos-upgrades/at-timestamp/${basename} [options]` + ) + process.exit(1) + } + const restArgs = args.slice(1) + const restOptions = parseOptions(restArgs) + if (basename === 'deploy') { + await arbosDeploy(version, restArgs) + } else { + await arbosDeployExecuteVerify(version, restOptions) + } + return + } + case 'execute': + await arbosExecute(args) + return + case 'verify': + await arbosVerify() + return + } + } + } + + if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) { + listDirectory(fullPath) + return + } + + if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) { + const content = fs.readFileSync(fullPath, 'utf-8') + console.log(content) + return + } + + die(`Not found: ${pathArg} + +Use 'help' to see available commands.`) +} diff --git a/src/cli/utils/auth.ts b/src/cli/utils/auth.ts new file mode 100644 index 00000000..7e719674 --- /dev/null +++ b/src/cli/utils/auth.ts @@ -0,0 +1,89 @@ +export interface DeployExecuteAuth { + deployKey: string + deployAccount: string + deployLedger: boolean + deployInteractive: boolean + executeKey: string + executeAccount: string + executeLedger: boolean + executeInteractive: boolean + dryRun: boolean + skipExecute: boolean + verifyContracts: boolean +} + +export function parseAuthArgs(args: string[]): string { + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg === '--private-key' || arg === '--account') { + const value = args[i + 1] + if (value) { + return `${arg} ${value}` + } + } + if (arg === '--ledger' || arg === '--interactive') { + return arg + } + } + return '' +} + +export function getDeployAuth(auth: DeployExecuteAuth): string { + if (auth.deployKey) { + return `--private-key ${auth.deployKey}` + } + if (auth.deployAccount) { + return `--account ${auth.deployAccount}` + } + if (auth.deployLedger) { + return '--ledger' + } + if (auth.deployInteractive) { + return '--interactive' + } + return '' +} + +export function getExecuteAuth(auth: DeployExecuteAuth): string { + if (auth.executeKey) { + return `--private-key ${auth.executeKey}` + } + if (auth.executeAccount) { + return `--account ${auth.executeAccount}` + } + if (auth.executeLedger) { + return '--ledger' + } + if (auth.executeInteractive) { + return '--interactive' + } + return '' +} + +export function createDeployExecuteAuth(options: { + deployKey?: string + deployAccount?: string + deployLedger?: boolean + deployInteractive?: boolean + executeKey?: string + executeAccount?: string + executeLedger?: boolean + executeInteractive?: boolean + dryRun?: boolean + skipExecute?: boolean + verify?: boolean +}): DeployExecuteAuth { + return { + deployKey: options.deployKey || '', + deployAccount: options.deployAccount || '', + deployLedger: options.deployLedger || false, + deployInteractive: options.deployInteractive || false, + executeKey: options.executeKey || '', + executeAccount: options.executeAccount || '', + executeLedger: options.executeLedger || false, + executeInteractive: options.executeInteractive || false, + dryRun: options.dryRun || false, + skipExecute: options.skipExecute || false, + verifyContracts: options.verify || false, + } +} diff --git a/src/cli/utils/env.ts b/src/cli/utils/env.ts new file mode 100644 index 00000000..195305b5 --- /dev/null +++ b/src/cli/utils/env.ts @@ -0,0 +1,48 @@ +import * as dotenv from 'dotenv' +import * as fs from 'fs' +import * as path from 'path' +import { die } from './log' + +function findRepoRoot(): string | null { + let dir = __dirname + for (let i = 0; i < 10; i++) { + if (fs.existsSync(path.join(dir, 'package.json'))) { + return dir + } + const parent = path.dirname(dir) + if (parent === dir) break + dir = parent + } + return null +} + +export function loadEnv(): void { + const envPath = path.join(process.cwd(), '.env') + if (fs.existsSync(envPath)) { + dotenv.config({ path: envPath }) + } +} + +export function requireEnv(name: string): string { + const value = process.env[name] + if (!value) { + die(`Required env var not set: ${name} (check your .env file)`) + } + return value +} + +export function getEnv(name: string): string | undefined { + return process.env[name] +} + +export function getScriptsDir(): string { + const repoRoot = findRepoRoot() + if (repoRoot) { + return path.join(repoRoot, 'scripts', 'foundry') + } + return '/app/scripts/foundry' +} + +export function getRepoRoot(): string { + return findRepoRoot() || '/app' +} diff --git a/src/cli/utils/forge.ts b/src/cli/utils/forge.ts new file mode 100644 index 00000000..b4e93473 --- /dev/null +++ b/src/cli/utils/forge.ts @@ -0,0 +1,168 @@ +import execa from 'execa' +import * as fs from 'fs' +import * as path from 'path' +import { die, log } from './log' +import { getRepoRoot } from './env' + +export interface ForgeScriptOptions { + script: string + rpcUrl: string + authArgs?: string + broadcast?: boolean + verify?: boolean + slow?: boolean + skipSimulation?: boolean + verbosity?: number +} + +export async function runForgeScript( + options: ForgeScriptOptions +): Promise { + const args = ['script', options.script, '--rpc-url', options.rpcUrl] + + if (options.slow) { + args.push('--slow') + } + + if (options.skipSimulation) { + args.push('--skip-simulation') + } + + const verbosity = options.verbosity ?? 3 + args.push('-' + 'v'.repeat(verbosity)) + + if (options.broadcast && options.authArgs) { + args.push('--broadcast') + args.push(...options.authArgs.split(' ').filter(Boolean)) + } + + if (options.verify) { + args.push('--verify') + } + + log(`Running: forge ${args.slice(0, 2).join(' ')}...`) + + const result = await execa('forge', args, { + stdio: 'inherit', + env: process.env, + }) + + if (result.exitCode !== 0) { + die(`Forge script failed with exit code ${result.exitCode}`) + } +} + +export interface CastSendOptions { + to: string + sig: string + args: string[] + rpcUrl: string + authArgs?: string +} + +export async function runCastSend(options: CastSendOptions): Promise { + const args = [ + 'send', + options.to, + options.sig, + ...options.args, + '--rpc-url', + options.rpcUrl, + ] + + if (options.authArgs) { + args.push(...options.authArgs.split(' ').filter(Boolean)) + } + + const result = await execa('cast', args, { + stdio: 'inherit', + env: process.env, + }) + + if (result.exitCode !== 0) { + die(`Cast send failed with exit code ${result.exitCode}`) + } +} + +export interface CastCallOptions { + to: string + sig: string + rpcUrl: string +} + +export async function runCastCall(options: CastCallOptions): Promise { + try { + const result = await execa('cast', [ + 'call', + '--rpc-url', + options.rpcUrl, + options.to, + options.sig, + ]) + return result.stdout + } catch { + return 'N/A' + } +} + +export async function castCalldata( + sig: string, + ...args: string[] +): Promise { + const result = await execa('cast', ['calldata', sig, ...args]) + return result.stdout +} + +export async function getChainId(rpcUrl: string): Promise { + const result = await execa('cast', ['chain-id', '--rpc-url', rpcUrl]) + return result.stdout.trim() +} + +export function parseActionAddress( + scriptPath: string, + chainId: string +): string { + const scriptName = path.basename(scriptPath) + const repoRoot = getRepoRoot() + const broadcastFile = path.join( + repoRoot, + 'broadcast', + scriptName, + chainId, + 'run-latest.json' + ) + + if (!fs.existsSync(broadcastFile)) { + die(`Broadcast file not found: ${broadcastFile}`) + } + + const content = JSON.parse(fs.readFileSync(broadcastFile, 'utf-8')) + const createTxs = content.transactions?.filter( + (tx: { transactionType: string }) => tx.transactionType === 'CREATE' + ) + + if (!createTxs || createTxs.length === 0) { + die('Could not parse action address from broadcast file') + } + + const address = createTxs[createTxs.length - 1]?.contractAddress + if (!address) { + die('Could not parse action address from broadcast file') + } + + return address +} + +export function findScript(dir: string, pattern: RegExp): string | null { + if (!fs.existsSync(dir)) { + return null + } + + const files = fs.readdirSync(dir) + for (const file of files) { + if (pattern.test(file) && file.endsWith('.s.sol')) { + return path.join(dir, file) + } + } + return null +} diff --git a/src/cli/utils/log.ts b/src/cli/utils/log.ts new file mode 100644 index 00000000..355c1699 --- /dev/null +++ b/src/cli/utils/log.ts @@ -0,0 +1,10 @@ +const PREFIX = '[orbit-actions]' + +export function log(message: string): void { + console.log(`${PREFIX} ${message}`) +} + +export function die(message: string): never { + console.error(`Error: ${message}`) + process.exit(1) +} diff --git a/test/docker/test-docker.bash b/test/docker/test-docker.bash new file mode 100755 index 00000000..d24cd210 --- /dev/null +++ b/test/docker/test-docker.bash @@ -0,0 +1,168 @@ +#!/bin/bash +set -euo pipefail + +# Docker smoke tests for orbit-actions +# Verifies that all required tools and scripts are accessible in the Docker image + +IMAGE_NAME="${DOCKER_IMAGE:-orbit-actions:test}" + +echo "=== Docker Smoke Tests ===" +echo "Image: $IMAGE_NAME" +echo "" + +# Track failures +FAILURES=0 + +run_test() { + local name="$1" + shift + echo -n "Testing $name... " + if "$@" > /dev/null 2>&1; then + echo "OK" + else + echo "FAILED" + FAILURES=$((FAILURES + 1)) + fi +} + +# Test 1: Tools are installed (via --entrypoint) +echo "--- Tools Installed ---" +run_test "forge" docker run --rm --entrypoint forge "$IMAGE_NAME" --version +run_test "cast" docker run --rm --entrypoint cast "$IMAGE_NAME" --version +run_test "yarn" docker run --rm --entrypoint yarn "$IMAGE_NAME" --version +run_test "node" docker run --rm --entrypoint node "$IMAGE_NAME" --version + +# Test 2: Dependencies are installed +echo "" +echo "--- Dependencies ---" +run_test "node_modules exists" docker run --rm --entrypoint test "$IMAGE_NAME" -d node_modules +run_test "forge dependencies" docker run --rm --entrypoint test "$IMAGE_NAME" -d node_modules/@arbitrum + +# Test 3: Contracts compile +echo "" +echo "--- Contract Compilation ---" +run_test "contracts built" docker run --rm --entrypoint test "$IMAGE_NAME" -d out + +# Test 4: Browsing - list directories +echo "" +echo "--- Directory Browsing ---" + +# List top level +echo -n "Testing list top level... " +TOP_OUTPUT=$(docker run --rm "$IMAGE_NAME" 2>&1) +if echo "$TOP_OUTPUT" | grep "contract-upgrades" > /dev/null; then + echo "OK" +else + echo "FAILED" + FAILURES=$((FAILURES + 1)) +fi + +# List contract-upgrades versions +echo -n "Testing list contract-upgrades... " +VERSIONS_OUTPUT=$(docker run --rm "$IMAGE_NAME" contract-upgrades 2>&1) +if echo "$VERSIONS_OUTPUT" | grep "1.2.1" > /dev/null && echo "$VERSIONS_OUTPUT" | grep "2.1.0" > /dev/null; then + echo "OK" +else + echo "FAILED" + FAILURES=$((FAILURES + 1)) +fi + +# List version contents (should show virtual commands) +echo -n "Testing list contract-upgrades/1.2.1... " +CONTENTS_OUTPUT=$(docker run --rm "$IMAGE_NAME" contract-upgrades/1.2.1 2>&1) +if echo "$CONTENTS_OUTPUT" | grep "deploy-execute-verify" > /dev/null; then + echo "OK" +else + echo "FAILED" + FAILURES=$((FAILURES + 1)) +fi + +# Test 5: File viewing +echo "" +echo "--- File Viewing ---" + +# View README +echo -n "Testing view README... " +README_OUTPUT=$(docker run --rm "$IMAGE_NAME" contract-upgrades/1.2.1/README.md 2>&1) +if echo "$README_OUTPUT" | grep -i "nitro" > /dev/null; then + echo "OK" +else + echo "FAILED" + FAILURES=$((FAILURES + 1)) +fi + +# View env template (1.2.1 has env-templates/) +echo -n "Testing view env template... " +ENV_OUTPUT=$(docker run --rm "$IMAGE_NAME" contract-upgrades/1.2.1/env-templates/.env.local-upgrade.example 2>&1) +if echo "$ENV_OUTPUT" | grep "INBOX_ADDRESS" > /dev/null; then + echo "OK" +else + echo "FAILED" + FAILURES=$((FAILURES + 1)) +fi + +# View .env.sample (2.1.0+ has .env.sample) +echo -n "Testing view .env.sample... " +SAMPLE_OUTPUT=$(docker run --rm "$IMAGE_NAME" contract-upgrades/2.1.0/.env.sample 2>&1) +if echo "$SAMPLE_OUTPUT" | grep "UPGRADE_ACTION_ADDRESS" > /dev/null; then + echo "OK" +else + echo "FAILED" + FAILURES=$((FAILURES + 1)) +fi + +# Test 6: Help +echo "" +echo "--- Help ---" +run_test "help command" docker run --rm "$IMAGE_NAME" help + +# Test 7: Yarn scripts work +echo "" +echo "--- Yarn Scripts ---" +run_test "yarn orbit:contracts:version --help" docker run --rm --entrypoint yarn "$IMAGE_NAME" orbit:contracts:version --help + +# Test 8: Unit tests pass +echo "" +echo "--- Unit Tests ---" +echo "Running unit tests inside container..." +if docker run --rm --entrypoint yarn "$IMAGE_NAME" test:unit; then + echo "Unit tests: OK" +else + echo "Unit tests: FAILED" + FAILURES=$((FAILURES + 1)) +fi + +# Test 9: Dry run tests (requires .env file) +echo "" +echo "--- Dry Run Tests ---" + +# Create a temporary .env file for testing arbos +TEMP_ENV=$(mktemp) +cat > "$TEMP_ENV" <&1) +if echo "$DRYRUN_OUTPUT" | grep "Calldata:" > /dev/null; then + echo "OK" +else + echo "FAILED" + FAILURES=$((FAILURES + 1)) +fi + +rm -f "$TEMP_ENV" + +# Summary +echo "" +echo "=== Summary ===" +if [ $FAILURES -eq 0 ]; then + echo "All tests passed!" + exit 0 +else + echo "$FAILURES test(s) failed" + exit 1 +fi diff --git a/test/local/test-local.bash b/test/local/test-local.bash new file mode 100755 index 00000000..ef5358ec --- /dev/null +++ b/test/local/test-local.bash @@ -0,0 +1,83 @@ +#!/bin/bash +set -euo pipefail + +# Local (non-Docker) smoke tests for orbit-actions CLI +# Tests the bin/router and related scripts directly + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +ROUTER="$REPO_ROOT/bin/router" + +echo "=== Local Smoke Tests ===" +echo "Router: $ROUTER" +echo "" + +PASSED=0 +FAILED=0 + +check() { + local name="$1" + shift + printf "Testing %s... " "$name" + if "$@" >/dev/null 2>&1; then + echo "OK" + PASSED=$((PASSED + 1)) + else + echo "FAILED" + FAILED=$((FAILED + 1)) + fi +} + +check_output() { + local name="$1" + local expected="$2" + shift 2 + printf "Testing %s... " "$name" + # Disable pipefail for this check - it interferes with if/pipe/grep + if (set +o pipefail; "$@" 2>&1 | grep -q "$expected"); then + echo "OK" + PASSED=$((PASSED + 1)) + else + echo "FAILED (expected: $expected)" + FAILED=$((FAILED + 1)) + fi +} + +echo "--- Prerequisites ---" +check "forge installed" command -v forge +check "cast installed" command -v cast +check "jq installed" command -v jq + +echo "" +echo "--- Directory Browsing ---" +check "list top level" "$ROUTER" +check_output "list contract-upgrades" "1.2.1" "$ROUTER" contract-upgrades +check_output "list contract-upgrades/1.2.1" "deploy" "$ROUTER" contract-upgrades/1.2.1 +check_output "list arbos-upgrades" "at-timestamp" "$ROUTER" arbos-upgrades + +echo "" +echo "--- File Viewing ---" +check_output "view README" "Nitro contracts" "$ROUTER" contract-upgrades/1.2.1/README.md + +echo "" +echo "--- Help ---" +check_output "help command" "Usage:" "$ROUTER" help +check_output "contract-upgrade help" "deploy-execute-verify" "$REPO_ROOT/bin/contract-upgrade" --help +check_output "arbos-upgrade help" "deploy-execute-verify" "$REPO_ROOT/bin/arbos-upgrade" --help + +echo "" +echo "--- Passthrough ---" +check_output "forge passthrough" "forge" "$ROUTER" forge --version +check_output "cast passthrough" "cast" "$ROUTER" cast --version + +echo "" +echo "=== Summary ===" +echo "Passed: $PASSED" +echo "Failed: $FAILED" + +if [[ $FAILED -gt 0 ]]; then + echo "Some tests failed!" + exit 1 +else + echo "All tests passed!" +fi diff --git a/tsconfig.cli.json b/tsconfig.cli.json new file mode 100644 index 00000000..8429e32d --- /dev/null +++ b/tsconfig.cli.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true + }, + "include": ["src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index 1cd2598d..15c2575d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2082,6 +2082,11 @@ commander@3.0.2: resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e" integrity sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow== +commander@^12.0.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" + integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== + compare-versions@^6.0.0: version "6.1.1" resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-6.1.1.tgz#7af3cc1099ba37d244b3145a9af5201b629148a9" @@ -2681,7 +2686,43 @@ ethereumjs-util@^7.0.3, ethereumjs-util@^7.1.4: ethereum-cryptography "^0.1.3" rlp "^2.2.4" -"ethers-v5@npm:ethers@^5.7.2", ethers@^5.7.1, ethers@^5.7.2: +"ethers-v5@npm:ethers@^5.7.2": + version "5.7.2" + resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.7.2.tgz#3a7deeabbb8c030d4126b24f84e525466145872e" + integrity sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg== + dependencies: + "@ethersproject/abi" "5.7.0" + "@ethersproject/abstract-provider" "5.7.0" + "@ethersproject/abstract-signer" "5.7.0" + "@ethersproject/address" "5.7.0" + "@ethersproject/base64" "5.7.0" + "@ethersproject/basex" "5.7.0" + "@ethersproject/bignumber" "5.7.0" + "@ethersproject/bytes" "5.7.0" + "@ethersproject/constants" "5.7.0" + "@ethersproject/contracts" "5.7.0" + "@ethersproject/hash" "5.7.0" + "@ethersproject/hdnode" "5.7.0" + "@ethersproject/json-wallets" "5.7.0" + "@ethersproject/keccak256" "5.7.0" + "@ethersproject/logger" "5.7.0" + "@ethersproject/networks" "5.7.1" + "@ethersproject/pbkdf2" "5.7.0" + "@ethersproject/properties" "5.7.0" + "@ethersproject/providers" "5.7.2" + "@ethersproject/random" "5.7.0" + "@ethersproject/rlp" "5.7.0" + "@ethersproject/sha2" "5.7.0" + "@ethersproject/signing-key" "5.7.0" + "@ethersproject/solidity" "5.7.0" + "@ethersproject/strings" "5.7.0" + "@ethersproject/transactions" "5.7.0" + "@ethersproject/units" "5.7.0" + "@ethersproject/wallet" "5.7.0" + "@ethersproject/web" "5.7.1" + "@ethersproject/wordlists" "5.7.0" + +ethers@^5.7.1, ethers@^5.7.2: version "5.7.2" resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.7.2.tgz#3a7deeabbb8c030d4126b24f84e525466145872e" integrity sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg== @@ -2767,6 +2808,21 @@ evp_bytestokey@^1.0.3: md5.js "^1.3.4" safe-buffer "^5.1.1" +execa@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" + integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -3017,6 +3073,11 @@ get-port@^3.1.0: resolved "https://registry.yarnpkg.com/get-port/-/get-port-3.2.0.tgz#dd7ce7de187c06c8bf353796ac71e099f0980ebc" integrity sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg== +get-stream@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + get-symbol-description@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz#533744d5aa20aca4e079c8e5daf7fd44202821f5" @@ -3391,6 +3452,11 @@ https-proxy-agent@^5.0.0: agent-base "6" debug "4" +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -3612,6 +3678,11 @@ is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: dependencies: call-bind "^1.0.7" +is-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + is-string@^1.0.5, is-string@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" @@ -3945,6 +4016,11 @@ memorystream@^0.3.1: resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2" integrity sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw== +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + merge2@^1.2.3, merge2@^1.3.0, merge2@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" @@ -3975,6 +4051,11 @@ mime-types@^2.1.12: dependencies: mime-db "1.52.0" +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" @@ -4132,6 +4213,13 @@ normalize-path@^3.0.0, normalize-path@~3.0.0: resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== +npm-run-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + number-to-bn@1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/number-to-bn/-/number-to-bn-1.7.0.tgz#bb3623592f7e5f9e0030b1977bd41a0c53fe1ea0" @@ -4177,6 +4265,13 @@ once@1.x, once@^1.3.0: dependencies: wrappy "1" +onetime@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + open@^7.4.2: version "7.4.2" resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" @@ -4311,7 +4406,7 @@ path-key@^2.0.1: resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" integrity sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw== -path-key@^3.1.0: +path-key@^3.0.0, path-key@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== @@ -4810,7 +4905,7 @@ side-channel@^1.0.4: get-intrinsic "^1.2.4" object-inspect "^1.13.1" -signal-exit@^3.0.2: +signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== @@ -5016,6 +5111,11 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + strip-hex-prefix@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/strip-hex-prefix/-/strip-hex-prefix-1.0.0.tgz#0c5f155fef1151373377de9dbb588da05500e36f"