diff --git a/examples/get-chain-contract-versions/.env.example b/examples/get-chain-contract-versions/.env.example new file mode 100644 index 00000000..1af59142 --- /dev/null +++ b/examples/get-chain-contract-versions/.env.example @@ -0,0 +1,4 @@ +INBOX_ADDRESS=0xaE21fDA3de92dE2FDAF606233b2863782Ba046F9 +NETWORK=arb1 +ORBIT_ACTIONS_IMAGE=offchainlabs/chain-actions +PARENT_CHAIN_RPC= diff --git a/examples/get-chain-contract-versions/README.md b/examples/get-chain-contract-versions/README.md new file mode 100644 index 00000000..0c93698c --- /dev/null +++ b/examples/get-chain-contract-versions/README.md @@ -0,0 +1,33 @@ +# Get Orbit chain contract versions + +This example uses the SDK's `getOrbitChainContractVersions` helper to inspect the deployed Nitro contract versions for an Orbit chain on Arbitrum. + +It runs against the current repository checkout, so `pnpm dev` builds the local SDK before executing the example. + +It requires: + +- Docker installed and available on `PATH` +- an inbox address for the chain you want to inspect +- a parent chain RPC URL + +## Setup + +1. Install dependencies + + ```bash + pnpm install + ``` + +2. Create `.env` + + ```bash + cp .env.example .env + ``` + +3. Run the example + + ```bash + pnpm dev + ``` + +The script prints the discovered contract versions and any upgrade recommendation as JSON. diff --git a/examples/get-chain-contract-versions/index.ts b/examples/get-chain-contract-versions/index.ts new file mode 100644 index 00000000..2a3cd546 --- /dev/null +++ b/examples/get-chain-contract-versions/index.ts @@ -0,0 +1,37 @@ +import { config } from 'dotenv'; +import { getOrbitChainContractVersions } from '@arbitrum/chain-sdk'; +import { isAddress, Address } from 'viem'; + +config(); + +const orbitActionsImage = process.env.ORBIT_ACTIONS_IMAGE ?? 'offchainlabs/chain-actions'; +const network = process.env.NETWORK ?? 'arb1'; +const inboxAddress = process.env.INBOX_ADDRESS; +const parentChainRpc = process.env.PARENT_CHAIN_RPC; + +if (!inboxAddress || !isAddress(inboxAddress)) { + throw new Error('Please provide the "INBOX_ADDRESS" environment variable'); +} + +if (!parentChainRpc || parentChainRpc === '') { + throw new Error('Please provide the "PARENT_CHAIN_RPC" environment variable'); +} + +async function main() { + console.log('Getting Orbit chain contract versions...'); + const result = await getOrbitChainContractVersions({ + image: orbitActionsImage, + inboxAddress: inboxAddress as Address, + network, + env: { + PARENT_CHAIN_RPC: parentChainRpc, + }, + }); + + console.log(result); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/examples/get-chain-contract-versions/package.json b/examples/get-chain-contract-versions/package.json new file mode 100644 index 00000000..eda1d960 --- /dev/null +++ b/examples/get-chain-contract-versions/package.json @@ -0,0 +1,17 @@ +{ + "name": "get-chain-contract-versions", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tsc --outDir dist && node ./dist/index.js", + "predev": "pnpm --dir ../.. build" + }, + "devDependencies": { + "@types/node": "^20.9.0", + "typescript": "^5.2.2" + }, + "dependencies": { + "@arbitrum/chain-sdk": "workspace:*" + } +} diff --git a/examples/get-chain-contract-versions/tsconfig.json b/examples/get-chain-contract-versions/tsconfig.json new file mode 100644 index 00000000..abf0a90d --- /dev/null +++ b/examples/get-chain-contract-versions/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["./**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52ac0aee..009b871f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,19 @@ importers: specifier: ^5.2.2 version: 5.2.2 + examples/get-chain-contract-versions: + dependencies: + '@arbitrum/chain-sdk': + specifier: workspace:* + version: link:../../src + devDependencies: + '@types/node': + specifier: ^20.9.0 + version: 20.9.0 + typescript: + specifier: ^5.2.2 + version: 5.2.2 + examples/prepare-node-config: devDependencies: '@types/node': diff --git a/src/constants.ts b/src/constants.ts index 09bbd19f..6def9aaa 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,3 +9,5 @@ export const createRollupDefaultRetryablesFees = parseEther('0.125'); * Approximate value necessary to pay for retryables fees for `createTokenBridge`. */ export const createTokenBridgeDefaultRetryablesFees = parseEther('0.02'); + +export const DEFAULT_ORBIT_ACTIONS_IMAGE = 'offchainlabs/chain-actions:150d84f832ea'; diff --git a/src/getOrbitChainContractVersions.ts b/src/getOrbitChainContractVersions.ts new file mode 100644 index 00000000..f22747e4 --- /dev/null +++ b/src/getOrbitChainContractVersions.ts @@ -0,0 +1,53 @@ +import type { Address } from 'viem'; + +import { runDockerCommand } from './runDockerCommand'; +import { DEFAULT_ORBIT_ACTIONS_IMAGE } from './constants'; + +export type GetOrbitChainContractVersionsParameters = { + image?: string; + inboxAddress: Address; + network: string; + env?: Record; +}; + +export type GetOrbitChainContractVersionsResult = { + versions: Record; + upgradeRecommendation: unknown; +}; + +export async function getOrbitChainContractVersions({ + image = DEFAULT_ORBIT_ACTIONS_IMAGE, + inboxAddress, + network, + env, +}: GetOrbitChainContractVersionsParameters): Promise { + const { stdout } = await runDockerCommand({ + image, + entrypoint: 'yarn', + command: ['--silent', 'orbit:contracts:version', '--network', network, '--no-compile'], + env: { + ...env, + INBOX_ADDRESS: inboxAddress, + JSON_OUTPUT: 'true', + }, + }); + + let parsed: unknown; + + try { + parsed = JSON.parse(stdout) as unknown; + } catch { + throw new Error('Failed to parse Orbit chain contract versions'); + } + + if ( + typeof parsed !== 'object' || + parsed === null || + !('versions' in parsed) || + !('upgradeRecommendation' in parsed) + ) { + throw new Error('Failed to parse Orbit chain contract versions'); + } + + return parsed as GetOrbitChainContractVersionsResult; +} diff --git a/src/getOrbitChainContractVersions.unit.test.ts b/src/getOrbitChainContractVersions.unit.test.ts new file mode 100644 index 00000000..13bd147d --- /dev/null +++ b/src/getOrbitChainContractVersions.unit.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getOrbitChainContractVersions } from './getOrbitChainContractVersions'; +import { runDockerCommand } from './runDockerCommand'; + +vi.mock('./runDockerCommand', () => ({ + runDockerCommand: vi.fn(), +})); + +describe('getOrbitChainContractVersions', () => { + beforeEach(() => { + vi.mocked(runDockerCommand).mockReset(); + }); + + it('uses the default Orbit Actions image through the internal docker runner', async () => { + vi.mocked(runDockerCommand).mockResolvedValueOnce({ + argv: ['docker', 'run'], + stdout: + '{"versions":{"Inbox":"v1.1.1","RollupProxy":"v1.1.1"},"upgradeRecommendation":{"message":"No upgrade path found"}}', + stderr: '', + exitCode: 0, + }); + + const result = await getOrbitChainContractVersions({ + inboxAddress: '0xaE21fDA3de92dE2FDAF606233b2863782Ba046F9', + network: 'arb1', + env: { + PARENT_CHAIN_RPC: 'https://rpc.example', + }, + }); + + expect(runDockerCommand).toHaveBeenCalledWith({ + image: 'offchainlabs/chain-actions:150d84f832ea', + entrypoint: 'yarn', + command: ['--silent', 'orbit:contracts:version', '--network', 'arb1', '--no-compile'], + env: { + PARENT_CHAIN_RPC: 'https://rpc.example', + INBOX_ADDRESS: '0xaE21fDA3de92dE2FDAF606233b2863782Ba046F9', + JSON_OUTPUT: 'true', + }, + }); + + expect(result).toEqual({ + versions: { + Inbox: 'v1.1.1', + RollupProxy: 'v1.1.1', + }, + upgradeRecommendation: { + message: 'No upgrade path found', + }, + }); + }); + + it('uses a custom Orbit Actions image when one is provided', async () => { + vi.mocked(runDockerCommand).mockResolvedValueOnce({ + argv: ['docker', 'run'], + stdout: + '{"versions":{"Inbox":"v1.1.1","Bridge":"v1.1.2","RollupProxy":null},"upgradeRecommendation":null}', + stderr: '', + exitCode: 0, + }); + + const result = await getOrbitChainContractVersions({ + image: 'offchainlabs/chain-actions:custom', + inboxAddress: '0xaE21fDA3de92dE2FDAF606233b2863782Ba046F9', + network: 'arb-sepolia', + }); + + expect(runDockerCommand).toHaveBeenCalledWith({ + image: 'offchainlabs/chain-actions:custom', + entrypoint: 'yarn', + command: ['--silent', 'orbit:contracts:version', '--network', 'arb-sepolia', '--no-compile'], + env: { + INBOX_ADDRESS: '0xaE21fDA3de92dE2FDAF606233b2863782Ba046F9', + JSON_OUTPUT: 'true', + }, + }); + + expect(result).toEqual({ + versions: { + Inbox: 'v1.1.1', + Bridge: 'v1.1.2', + RollupProxy: null, + }, + upgradeRecommendation: null, + }); + }); + + it('throws when the docker output does not contain a valid JSON payload', async () => { + vi.mocked(runDockerCommand).mockResolvedValueOnce({ + argv: ['docker', 'run'], + stdout: 'not json', + stderr: '', + exitCode: 0, + }); + + await expect( + getOrbitChainContractVersions({ + inboxAddress: '0xaE21fDA3de92dE2FDAF606233b2863782Ba046F9', + network: 'arb1', + }), + ).rejects.toThrow('Failed to parse Orbit chain contract versions'); + }); + + it.each(['null', '"unexpected"', '42', 'true', '[]'])( + 'throws when the parsed JSON payload is not an object: %s', + async (stdout) => { + vi.mocked(runDockerCommand).mockResolvedValueOnce({ + argv: ['docker', 'run'], + stdout, + stderr: '', + exitCode: 0, + }); + + await expect( + getOrbitChainContractVersions({ + inboxAddress: '0xaE21fDA3de92dE2FDAF606233b2863782Ba046F9', + network: 'arb1', + }), + ).rejects.toThrow('Failed to parse Orbit chain contract versions'); + }, + ); + + it('throws when the JSON payload is missing top level fields', async () => { + vi.mocked(runDockerCommand).mockResolvedValueOnce({ + argv: ['docker', 'run'], + stdout: '{"versions":{"Inbox":"v1.1.1"}}', + stderr: '', + exitCode: 0, + }); + + await expect( + getOrbitChainContractVersions({ + inboxAddress: '0xaE21fDA3de92dE2FDAF606233b2863782Ba046F9', + network: 'arb1', + }), + ).rejects.toThrow('Failed to parse Orbit chain contract versions'); + }); + + it('accepts any JSON payload that includes versions and upgradeRecommendation', async () => { + vi.mocked(runDockerCommand).mockResolvedValueOnce({ + argv: ['docker', 'run'], + stdout: '{"versions":"not-validated","upgradeRecommendation":null}', + stderr: '', + exitCode: 0, + }); + + await expect( + getOrbitChainContractVersions({ + inboxAddress: '0xaE21fDA3de92dE2FDAF606233b2863782Ba046F9', + network: 'arb1', + }), + ).resolves.toEqual({ + versions: 'not-validated', + upgradeRecommendation: null, + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index e05d0fe4..a4d6887f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -191,6 +191,7 @@ import { FetchDecimalsProps, } from './utils/erc20'; import { prepareArbitrumNetwork } from './utils/registerNewNetwork'; +import { getOrbitChainContractVersions } from './getOrbitChainContractVersions'; export { arbOwnerPublicActions, @@ -354,4 +355,6 @@ export { FetchDecimalsProps, // prepareArbitrumNetwork, + // + getOrbitChainContractVersions, }; diff --git a/src/runDockerCommand.ts b/src/runDockerCommand.ts index d81e450f..072b1550 100644 --- a/src/runDockerCommand.ts +++ b/src/runDockerCommand.ts @@ -14,26 +14,10 @@ export type RunDockerCommandResult = { exitCode: number; }; -export function runDockerCommand( - params: RunDockerCommandParameters, +function executeDockerCommand( + argv: string[], + environment: NodeJS.ProcessEnv, ): Promise { - const envEntries = Object.entries(params.env ?? {}).filter((entry) => entry[1] !== undefined); - - const argv = [ - 'docker', - 'run', - '--rm', - ...envEntries.flatMap(([name]) => ['--env', name]), - ...(params.entrypoint ? ['--entrypoint', params.entrypoint] : []), - params.image, - ...(params.command ?? []), - ]; - - const environment = { - ...process.env, - ...Object.fromEntries(envEntries), - }; - return new Promise((resolve, reject) => { execFile('docker', argv.slice(1), { env: environment }, (error, stdout, stderr) => { if (error) { @@ -58,3 +42,61 @@ export function runDockerCommand( }); }); } + +function isMissingDockerImageError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const stderr = 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : ''; + + return ( + stderr.includes('No such object') || + stderr.includes('No such image') || + error.message.includes('No such object') || + error.message.includes('No such image') + ); +} + +async function ensureDockerImageAvailable(image: string): Promise { + console.log(`Checking Docker image "${image}"...`); + + try { + await executeDockerCommand(['docker', 'image', 'inspect', image], process.env); + console.log(`Docker image "${image}" is already available locally.`); + return; + } catch (error) { + if (!isMissingDockerImageError(error)) { + throw error; + } + } + + console.log(`Docker image "${image}" is missing locally. Pulling it now...`); + await executeDockerCommand(['docker', 'pull', image], process.env); + console.log(`Docker image "${image}" was pulled successfully.`); +} + +export async function runDockerCommand( + params: RunDockerCommandParameters, +): Promise { + const envEntries = Object.entries(params.env ?? {}).filter((entry) => entry[1] !== undefined); + + const argv = [ + 'docker', + 'run', + '--rm', + ...envEntries.flatMap(([name]) => ['--env', name]), + ...(params.entrypoint ? ['--entrypoint', params.entrypoint] : []), + params.image, + ...(params.command ?? []), + ]; + + const environment = { + ...process.env, + ...Object.fromEntries(envEntries), + }; + + await ensureDockerImageAvailable(params.image); + + return executeDockerCommand(argv, environment); +} diff --git a/src/runDockerCommand.unit.test.ts b/src/runDockerCommand.unit.test.ts index c6043c3d..e42d6393 100644 --- a/src/runDockerCommand.unit.test.ts +++ b/src/runDockerCommand.unit.test.ts @@ -13,15 +13,23 @@ vi.mock('node:child_process', () => ({ import { runDockerCommand } from './runDockerCommand'; describe('runDockerCommand', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + beforeEach(() => { + consoleLogSpy.mockClear(); execFileMock.mockReset(); }); it('passes env values through the child process instead of the docker argv', async () => { - execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { - callback(null, 'ok', ''); - return {} as never; - }); + execFileMock + .mockImplementationOnce((_file, _args, _options, callback) => { + callback(null, '[]', ''); + return {} as never; + }) + .mockImplementationOnce((_file, _args, _options, callback) => { + callback(null, 'ok', ''); + return {} as never; + }); const result = await runDockerCommand({ image: 'offchainlabs/chain-actions', @@ -31,7 +39,17 @@ describe('runDockerCommand', () => { }, }); - expect(execFileMock).toHaveBeenCalledWith( + expect(execFileMock).toHaveBeenNthCalledWith( + 1, + 'docker', + ['image', 'inspect', 'offchainlabs/chain-actions'], + expect.objectContaining({ + env: process.env, + }), + expect.any(Function), + ); + expect(execFileMock).toHaveBeenNthCalledWith( + 2, 'docker', [ 'run', @@ -63,19 +81,30 @@ describe('runDockerCommand', () => { exitCode: 0, }); expect(result.argv.join(' ')).not.toContain('my-secret-key'); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Checking Docker image "offchainlabs/chain-actions"...', + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Docker image "offchainlabs/chain-actions" is already available locally.', + ); }); it('does not leak env values in failure metadata', async () => { - execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { - callback( - Object.assign(new Error('Command failed'), { - code: 17, - }) as ExecFileException, - '', - 'bad', - ); - return {} as never; - }); + execFileMock + .mockImplementationOnce((_file, _args, _options, callback) => { + callback(null, '[]', ''); + return {} as never; + }) + .mockImplementationOnce((_file, _args, _options, callback) => { + callback( + Object.assign(new Error('Command failed'), { + code: 17, + }) as ExecFileException, + '', + 'bad', + ); + return {} as never; + }); const promise = runDockerCommand({ image: 'offchainlabs/chain-actions', @@ -107,4 +136,71 @@ describe('runDockerCommand', () => { expect(error.message).not.toContain('my-secret-key'); expect(error.argv.join(' ')).not.toContain('my-secret-key'); }); + + it('pulls missing images before running the container', async () => { + execFileMock + .mockImplementationOnce((_file, _args, _options, callback) => { + callback( + Object.assign(new Error('Command failed'), { + code: 1, + }) as ExecFileException, + '', + 'Error: No such object: offchainlabs/chain-actions', + ); + return {} as never; + }) + .mockImplementationOnce((_file, _args, _options, callback) => { + callback(null, 'Pulled', ''); + return {} as never; + }) + .mockImplementationOnce((_file, _args, _options, callback) => { + callback(null, 'ok', ''); + return {} as never; + }); + + const result = await runDockerCommand({ + image: 'offchainlabs/chain-actions', + command: ['orbit:contracts:version'], + }); + + expect(execFileMock).toHaveBeenNthCalledWith( + 1, + 'docker', + ['image', 'inspect', 'offchainlabs/chain-actions'], + expect.objectContaining({ + env: process.env, + }), + expect.any(Function), + ); + expect(execFileMock).toHaveBeenNthCalledWith( + 2, + 'docker', + ['pull', 'offchainlabs/chain-actions'], + expect.objectContaining({ + env: process.env, + }), + expect.any(Function), + ); + expect(execFileMock).toHaveBeenNthCalledWith( + 3, + 'docker', + ['run', '--rm', 'offchainlabs/chain-actions', 'orbit:contracts:version'], + expect.objectContaining({ + env: process.env, + }), + expect.any(Function), + ); + expect(result).toEqual({ + argv: ['docker', 'run', '--rm', 'offchainlabs/chain-actions', 'orbit:contracts:version'], + stdout: 'ok', + stderr: '', + exitCode: 0, + }); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Docker image "offchainlabs/chain-actions" is missing locally. Pulling it now...', + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Docker image "offchainlabs/chain-actions" was pulled successfully.', + ); + }); });