diff --git a/src/getNitroContractVersions.ts b/src/getNitroContractVersions.ts index c25af9b2..d0c52fff 100644 --- a/src/getNitroContractVersions.ts +++ b/src/getNitroContractVersions.ts @@ -1,6 +1,7 @@ import type { Address } from 'viem'; import { runChainVersioner } from '@arbitrum/chain-actions'; +import { verifyFoundryBinaries } from './utils/verifyFoundry'; export type GetNitroContractVersionsResult = { versions: Record; @@ -37,6 +38,8 @@ export async function getNitroContractVersions( inboxAddress: Address, parentChainRpc: string, ): Promise { + await verifyFoundryBinaries(); + const result = await runChainVersioner(inboxAddress, parentChainRpc, true); return parseNitroContractVersionsResult(result); diff --git a/src/getNitroContractVersions.unit.test.ts b/src/getNitroContractVersions.unit.test.ts index a7557d31..46231b90 100644 --- a/src/getNitroContractVersions.unit.test.ts +++ b/src/getNitroContractVersions.unit.test.ts @@ -42,7 +42,7 @@ describe('getNitroContractVersions', () => { ); }); - it('throws when the JSON payload is missing top level fields', async () => { + it('throws when the result is missing top-level fields', async () => { vi.mocked(runChainVersioner).mockResolvedValueOnce({ versions: { Inbox: 'v1.1.1' }, } as never); @@ -52,7 +52,7 @@ describe('getNitroContractVersions', () => { ).rejects.toThrow('Failed to parse Nitro contract versions'); }); - it('accepts any JSON payload that includes versions and upgradeRecommendation', async () => { + it('accepts any result that includes versions and upgradeRecommendation', async () => { vi.mocked(runChainVersioner).mockResolvedValueOnce({ versions: 'not-validated', upgradeRecommendation: null, diff --git a/src/index.ts b/src/index.ts index 2201fabb..efb9c1ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -207,6 +207,15 @@ import { } from './utils/erc20'; import { prepareArbitrumNetwork } from './utils/registerNewNetwork'; import { getNitroContractVersions } from './getNitroContractVersions'; +import { + NitroContractsUpgradeVersion, + DeployNitroContractsUpgradeActionParameters, + ExecuteNitroContractsUpgradeParameters, + VerifyNitroContractsUpgradeParameters, + deployNitroContractsUpgradeAction, + executeNitroContractsUpgrade, + verifyNitroContractsUpgrade, +} from './nitroContractsUpgrade'; export { arbOwnerPublicActions, @@ -383,6 +392,13 @@ export { prepareArbitrumNetwork, // getNitroContractVersions, + NitroContractsUpgradeVersion, + deployNitroContractsUpgradeAction, + DeployNitroContractsUpgradeActionParameters, + executeNitroContractsUpgrade, + ExecuteNitroContractsUpgradeParameters, + verifyNitroContractsUpgrade, + VerifyNitroContractsUpgradeParameters, }; export * from './scripting/scriptUtils'; diff --git a/src/nitroContractsUpgrade.ts b/src/nitroContractsUpgrade.ts new file mode 100644 index 00000000..443d21c7 --- /dev/null +++ b/src/nitroContractsUpgrade.ts @@ -0,0 +1,97 @@ +import { dirname, resolve } from 'node:path'; + +import type { Address } from 'viem'; + +import { runForgeScript, type RunForgeScriptResult } from './utils/runForgeScript'; + +export enum NitroContractsUpgradeVersion { + V3_2_0 = '3.2.0', +} + +export type DeployNitroContractsUpgradeActionParameters = { + version: NitroContractsUpgradeVersion; + parentChainRpcUrl: string; + forgeArgs?: string[]; +}; + +export type ExecuteNitroContractsUpgradeParameters = DeployNitroContractsUpgradeActionParameters & { + rollupAddress: Address; + parentUpgradeExecutorAddress: Address; + upgradeActionAddress: Address; +}; + +export type VerifyNitroContractsUpgradeParameters = DeployNitroContractsUpgradeActionParameters & { + rollupAddress: Address; +}; + +type UpgradeOperation = 'Deploy' | 'Execute' | 'Verify'; + +const upgradeScripts = { + [NitroContractsUpgradeVersion.V3_2_0]: { + Deploy: require.resolve( + '@arbitrum/chain-actions/scripts/foundry/contract-upgrades/3.2.0/DeployNitroContracts3Point2Point0UpgradeAction.s.sol', + ), + Execute: require.resolve( + '@arbitrum/chain-actions/scripts/foundry/contract-upgrades/3.2.0/ExecuteNitroContracts3Point2Point0Upgrade.s.sol', + ), + Verify: require.resolve( + '@arbitrum/chain-actions/scripts/foundry/contract-upgrades/3.2.0/VerifyNitroContracts3Point2Point0Upgrade.s.sol', + ), + }, +} satisfies Record>; + +function runUpgradeScript( + operation: UpgradeOperation, + params: DeployNitroContractsUpgradeActionParameters, + scriptEnvironment: Record = {}, +): Promise { + const chainActionsRoot = resolve(dirname(require.resolve('@arbitrum/chain-actions')), '../../..'); + const upgradeExecutorRoot = resolve( + dirname( + require.resolve('@offchainlabs/upgrade-executor/src/IUpgradeExecutor.sol', { + paths: [chainActionsRoot], + }), + ), + '..', + ); + + return runForgeScript({ + script: upgradeScripts[params.version][operation], + rpcUrl: params.parentChainRpcUrl, + forgeArgs: [ + '--root', + chainActionsRoot, + '--remappings', + `@offchainlabs/upgrade-executor/=${upgradeExecutorRoot}/`, + ...(params.forgeArgs ?? []), + ], + env: scriptEnvironment, + }); +} + +/** Deploys the upgrade action for a Nitro contracts version. */ +export function deployNitroContractsUpgradeAction( + params: DeployNitroContractsUpgradeActionParameters, +): Promise { + return runUpgradeScript('Deploy', params); +} + +/** Executes a Nitro contracts upgrade through the parent chain UpgradeExecutor. */ +export function executeNitroContractsUpgrade( + params: ExecuteNitroContractsUpgradeParameters, +): Promise { + return runUpgradeScript('Execute', params, { + ROLLUP_ADDRESS: params.rollupAddress, + PARENT_UPGRADE_EXECUTOR_ADDRESS: params.parentUpgradeExecutorAddress, + UPGRADE_ACTION_ADDRESS: params.upgradeActionAddress, + }); +} + +/** Verifies that a Nitro contracts upgrade was applied to the rollup. */ +export function verifyNitroContractsUpgrade( + params: VerifyNitroContractsUpgradeParameters, +): Promise { + return runUpgradeScript('Verify', params, { + ROLLUP_ADDRESS: params.rollupAddress, + }); +} diff --git a/src/scripting/commands.ts b/src/scripting/commands.ts index cf783e81..b43acb3b 100644 --- a/src/scripting/commands.ts +++ b/src/scripting/commands.ts @@ -130,6 +130,8 @@ import { schema as deployFullChainSchema, execute as deployFullChainExecute, } from './workflows/deployFullChain'; +import { getNitroContractVersions } from '../getNitroContractVersions'; +import { getNitroContractVersionsSchema } from './schemas/getNitroContractVersions'; import { contractRegistry } from './contractRegistry'; import { buildContractCommandSchema } from './contractCommandSchema'; @@ -366,4 +368,5 @@ export const commands: readonly Command[] = [ ), ...contractCommands, + command('getNitroContractVersions', getNitroContractVersionsSchema, getNitroContractVersions), ]; diff --git a/src/scripting/schemaCoverage.ts b/src/scripting/schemaCoverage.ts index dcaf8c92..ca6bf9d6 100644 --- a/src/scripting/schemaCoverage.ts +++ b/src/scripting/schemaCoverage.ts @@ -407,6 +407,9 @@ vi.mock('viem', async (importOriginal) => { }; }); +vi.mock('../getNitroContractVersions', () => ({ + getNitroContractVersions: _mocks.fn('getNitroContractVersions'), +})); /** * A testable leaf of a schema -- a scalar field the harness will vary when * running coverage. diff --git a/src/scripting/schemas/getNitroContractVersions.ts b/src/scripting/schemas/getNitroContractVersions.ts new file mode 100644 index 00000000..75717ad8 --- /dev/null +++ b/src/scripting/schemas/getNitroContractVersions.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { getNitroContractVersions } from '../../getNitroContractVersions'; +import { addressSchema } from './common'; + +export const getNitroContractVersionsSchema = z + .object({ + inboxAddress: addressSchema, + parentChainRpc: z.url(), + }) + .strict() + .transform( + (input): Parameters => [ + input.inboxAddress, + input.parentChainRpc, + ], + ); diff --git a/src/scripting/schemas/index.ts b/src/scripting/schemas/index.ts index 5f73f2b1..fb73607c 100644 --- a/src/scripting/schemas/index.ts +++ b/src/scripting/schemas/index.ts @@ -81,3 +81,4 @@ export { isAllowListEnabledSchema, isAllowedSchema, } from './actions'; +export { getNitroContractVersionsSchema } from './getNitroContractVersions'; diff --git a/src/utils/index.ts b/src/utils/index.ts index 17b77bb7..755d1ec0 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -7,6 +7,7 @@ import { getClientVersion } from './getClientVersion'; import { getRollupCreatorAddress } from './getRollupCreatorAddress'; import { getTokenBridgeCreatorAddress } from './getTokenBridgeCreatorAddress'; import { getWethAddress } from './getWethAddress'; +import { verifyFoundryBinaries } from './verifyFoundry'; export { generateChainId, @@ -18,4 +19,5 @@ export { getRollupCreatorAddress, getTokenBridgeCreatorAddress, getWethAddress, + verifyFoundryBinaries, }; diff --git a/src/utils/runForgeScript.ts b/src/utils/runForgeScript.ts new file mode 100644 index 00000000..c85650e4 --- /dev/null +++ b/src/utils/runForgeScript.ts @@ -0,0 +1,84 @@ +import { spawn } from 'node:child_process'; + +import { verifyFoundryBinaries } from './verifyFoundry'; + +export type RunForgeScriptParameters = { + script: string; + rpcUrl: string; + cwd?: string; + forgeArgs?: string[]; + env?: Record; +}; + +export type RunForgeScriptResult = { + stdout: string; + stderr: string; + exitCode: number; +}; + +/** Runs a Forge script after verifying that Foundry is installed. */ +export async function runForgeScript({ + script, + rpcUrl, + cwd, + forgeArgs = [], + env = {}, +}: RunForgeScriptParameters): Promise { + await verifyFoundryBinaries(); + + const args = ['script', script, '--rpc-url', rpcUrl, ...forgeArgs]; + const environment = { + ...process.env, + ...Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + }; + + return new Promise((resolve, reject) => { + const child = spawn('forge', args, { + cwd, + env: environment, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + + child.once('error', (cause) => { + reject( + Object.assign(new Error(`Unable to start Forge script: ${cause.message}`), { + name: 'RunForgeScriptError', + stdout, + stderr, + exitCode: 1, + cause, + }), + ); + }); + + child.once('close', (exitCode, signal) => { + if (exitCode !== 0) { + const reason = signal ? `signal ${signal}` : `exit code ${exitCode ?? 1}`; + reject( + Object.assign(new Error(`Forge script failed with ${reason}`), { + name: 'RunForgeScriptError', + stdout, + stderr, + exitCode: exitCode ?? 1, + }), + ); + return; + } + + resolve({ stdout, stderr, exitCode }); + }); + }); +} diff --git a/src/utils/verifyFoundry.ts b/src/utils/verifyFoundry.ts new file mode 100644 index 00000000..e23ff037 --- /dev/null +++ b/src/utils/verifyFoundry.ts @@ -0,0 +1,47 @@ +import { execFile } from 'node:child_process'; + +const FOUNDRY_BINARIES: ['forge', 'cast'] = ['forge', 'cast']; + +function runVersionCommand(binary: 'forge' | 'cast'): Promise { + return new Promise((resolve, reject) => { + execFile(binary, ['--version'], (error, stdout) => { + if (error) { + reject(error); + return; + } + + resolve(stdout); + }); + }); +} + +export async function verifyFoundryBinaries(stableOnly = true) { + const results = await Promise.allSettled( + FOUNDRY_BINARIES.map((binary) => runVersionCommand(binary)), + ); + + const binariesPresent = results.every((result) => result.status === 'fulfilled'); + + const stableReleaseInstalled = results.every( + (result) => + result.status === 'fulfilled' && result.value && isStableFoundryRelease(result.value), + ); + + if (!binariesPresent) { + throw new Error( + 'Foundry is required to run this operation. Install Foundry and make sure forge and cast are available on PATH.', + ); + } + + if (stableOnly && !stableReleaseInstalled) { + throw new Error( + 'Foundry stable releases are required to run this operation. Please install the stable versions of forge and cast.', + ); + } +} + +function isStableFoundryRelease(version: string): boolean { + return !['nightly', 'dev', 'alpha', 'beta', 'rc', 'preview'].some((tag) => + version.toLowerCase().includes(tag), + ); +}