From 5441451af046482db4d0543943403e49c1cd2583 Mon Sep 17 00:00:00 2001 From: Eric Lau Date: Fri, 9 Feb 2024 10:54:29 -0500 Subject: [PATCH] Support proposing upgrades (#2) --- .github/workflows/test.yml | 20 ++++ CHANGELOG.md | 9 ++ README.md | 32 +++++- ava.config.js | 3 + package.json | 5 +- src/cli.ts | 2 +- src/command-selector.ts | 34 ++++++ src/{defender.ts => commands/deploy.ts} | 37 ++---- src/commands/propose-upgrade.ts | 107 ++++++++++++++++++ src/internal/client.ts | 13 +++ .../deploy-contract.ts} | 12 +- src/internal/upgrade-contract.ts | 32 ++++++ src/internal/utils.ts | 20 ++++ test/cli.js | 22 ++++ test/cli.js.md | 37 ++++++ test/cli.js.snap | Bin 0 -> 326 bytes test/deploy.js | 91 +++++++++++++++ test/deploy.js.md | 29 +++++ test/deploy.js.snap | Bin 0 -> 783 bytes test/input/MyContract.json | 8 ++ test/input/build-info.json | 1 + test/propose-upgrade.js | 77 +++++++++++++ test/propose-upgrade.js.md | 25 ++++ test/propose-upgrade.js.snap | Bin 0 -> 605 bytes yarn.lock | 95 ++++++++++++++++ 25 files changed, 668 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 CHANGELOG.md create mode 100644 ava.config.js create mode 100644 src/command-selector.ts rename src/{defender.ts => commands/deploy.ts} (78%) create mode 100644 src/commands/propose-upgrade.ts create mode 100644 src/internal/client.ts rename src/{deployContract.ts => internal/deploy-contract.ts} (85%) create mode 100644 src/internal/upgrade-contract.ts create mode 100644 src/internal/utils.ts create mode 100644 test/cli.js create mode 100644 test/cli.js.md create mode 100644 test/cli.js.snap create mode 100644 test/deploy.js create mode 100644 test/deploy.js.md create mode 100644 test/deploy.js.snap create mode 100644 test/input/MyContract.json create mode 100644 test/input/build-info.json create mode 100644 test/propose-upgrade.js create mode 100644 test/propose-upgrade.js.md create mode 100644 test/propose-upgrade.js.snap diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f59c866 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Test + +on: + push: + branches: [main] + pull_request: {} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18.x + cache: 'yarn' + - name: Install dependencies + run: yarn install + - name: Run tests + run: yarn test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e72c2ef --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 0.0.1-alpha.3 (2024-02-09) + +- Support proposing upgrades. ([#2](https://github.com/OpenZeppelin/defender-deploy-client-cli/pull/2)) + +## 0.0.1-alpha.2 (2024-02-01) + +- Enable constructorBytecode argument. ([#1](https://github.com/OpenZeppelin/defender-deploy-client-cli/pull/1)) diff --git a/README.md b/README.md index fad7edb..6dd3f92 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,20 @@ DEFENDER_SECRET ## Usage ``` -npx @openzeppelin/defender-deploy-client-cli deploy --contractName --contractPath --chainId --artifactFile [--constructorBytecode ] [--licenseType ] [--verifySourceCode ] [--relayerId ] [--salt ] [--createFactoryAddress ] +Usage: npx @openzeppelin/defender-deploy-client-cli + +Performs actions using OpenZeppelin Defender. + +Available commands: + deploy Deploys a contract. + proposeUpgrade Proposes an upgrade. + +Run 'npx @openzeppelin/defender-deploy-client-cli --help' for more information on a command. +``` + +### Deploying a contract +``` +npx @openzeppelin/defender-deploy-client-cli deploy --contractName --contractPath --chainId --artifactFile [--constructorBytecode ] [--licenseType ] [--verifySourceCode ] [--relayerId ] [--salt ] [--createFactoryAddress ] Deploys a contract using OpenZeppelin Defender. @@ -36,4 +49,21 @@ Additional options: --relayerId Relayer ID to use for deployment. Defaults to the relayer configured for your deployment environment on Defender. --salt Salt to use for CREATE2 deployment. Defaults to a random salt. --createFactoryAddress Address of the CREATE2 factory to use for deployment. Defaults to the factory provided by Defender. +``` + +### Proposing an upgrade +``` +npx @openzeppelin/defender-deploy-client-cli proposeUpgrade --proxyAddress --newImplementationAddress --chainId [--proxyAdminAddress ] [--abiFile ] [--approvalProcessId ] + +Proposes an upgrade using OpenZeppelin Defender. + +Required options: + --proxyAddress Address of the proxy to upgrade. + --newImplementationAddress Address of the new implementation contract. + --chainId Chain ID of the network to use. + +Additional options: + --proxyAdminAddress Address of the proxy's admin. Required if the proxy is a transparent proxy. + --abiFile Path to a JSON file that contains an "abi" entry, where its value will be used as the new implementation ABI. + --approvalProcessId The ID of the upgrade approval process. Defaults to the upgrade approval process configured for your deployment environment on Defender. ``` \ No newline at end of file diff --git a/ava.config.js b/ava.config.js new file mode 100644 index 0000000..0a99a06 --- /dev/null +++ b/ava.config.js @@ -0,0 +1,3 @@ +module.exports = { + snapshotDir: '.', +}; diff --git a/package.json b/package.json index f2c97d1..c8ab4a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openzeppelin/defender-deploy-client-cli", - "version": "0.0.1-alpha.2", + "version": "0.0.1-alpha.3", "description": "CLI for deployments using OpenZeppelin Defender SDK", "repository": "https://github.com/OpenZeppelin/defender-deploy-client-cli", "license": "MIT", @@ -19,7 +19,8 @@ "@types/minimist": "^1.2.5", "ava": "^6.0.0", "rimraf": "^5.0.0", - "typescript": "^4.0.0" + "typescript": "^4.0.0", + "sinon": "^17.0.1" }, "dependencies": { "minimist": "^1.2.8", diff --git a/src/cli.ts b/src/cli.ts index c96caec..21b81ff 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { main } from './defender'; +import { main } from "./command-selector"; const run = async () => { await main(process.argv.slice(2)); diff --git a/src/command-selector.ts b/src/command-selector.ts new file mode 100644 index 0000000..0d00075 --- /dev/null +++ b/src/command-selector.ts @@ -0,0 +1,34 @@ +import minimist from "minimist"; +import { deploy } from './commands/deploy'; +import { proposeUpgrade } from './commands/propose-upgrade'; + +const USAGE = 'Usage: npx @openzeppelin/defender-deploy-client-cli '; +const DETAILS = ` +Performs actions using OpenZeppelin Defender. + +Available commands: + deploy Deploys a contract. + proposeUpgrade Proposes an upgrade. + +Run 'npx @openzeppelin/defender-deploy-client-cli --help' for more information on a command. +`; + +export async function main(args: string[]): Promise { + const regularArgs = minimist(args)._; + + if (regularArgs.length === 0) { + console.log(USAGE); + console.log(DETAILS); + } else { + if (regularArgs[0] === 'deploy') { + await deploy(args.slice(1)); + } else if (regularArgs[0] === 'proposeUpgrade') { + await proposeUpgrade(args.slice(1)); + } else { + throw new Error(`\ +Unknown command: ${regularArgs[0]} +Run 'npx @openzeppelin/defender-deploy-client-cli --help' for usage.\ +`); + } + } +} diff --git a/src/defender.ts b/src/commands/deploy.ts similarity index 78% rename from src/defender.ts rename to src/commands/deploy.ts index 4bb90e3..db5b328 100644 --- a/src/defender.ts +++ b/src/commands/deploy.ts @@ -1,6 +1,8 @@ import minimist from 'minimist'; -import { FunctionArgs, deployContract } from './deployContract'; -import { Network, fromChainId } from '@openzeppelin/defender-sdk-base-client'; +import { FunctionArgs, deployContract } from '../internal/deploy-contract'; +import { getDeployClient } from '../internal/client'; +import { getAndValidateString, getNetwork } from '../internal/utils'; +import { DeployClient } from '@openzeppelin/defender-sdk-deploy-client'; const USAGE = 'Usage: npx @openzeppelin/defender-deploy-client-cli deploy --contractName --contractPath --chainId --artifactFile [--constructorBytecode ] [--licenseType ] [--verifySourceCode ] [--relayerId ] [--salt ] [--createFactoryAddress ]'; const DETAILS = ` @@ -21,13 +23,14 @@ Additional options: --createFactoryAddress Address of the CREATE2 factory to use for deployment. Defaults to the factory provided by Defender. `; -export async function main(args: string[]): Promise { +export async function deploy(args: string[], deployClient?: DeployClient): Promise { const { parsedArgs, extraArgs } = parseArgs(args); if (!help(parsedArgs, extraArgs)) { const functionArgs = getFunctionArgs(parsedArgs, extraArgs); + const client = deployClient ?? getDeployClient(); + const address = await deployContract(functionArgs, client); - const address = await deployContract(functionArgs); console.log(`Deployed to address: ${address}`); } } @@ -47,7 +50,7 @@ function parseArgs(args: string[]) { } function help(parsedArgs: minimist.ParsedArgs, extraArgs: string[]): boolean { - if (extraArgs.length === 0 || parsedArgs['help']) { + if (parsedArgs['help']) { console.log(USAGE); console.log(DETAILS); return true; @@ -62,11 +65,7 @@ function help(parsedArgs: minimist.ParsedArgs, extraArgs: string[]): boolean { * @throws Error if any arguments or options are invalid. */ export function getFunctionArgs(parsedArgs: minimist.ParsedArgs, extraArgs: string[]): FunctionArgs { - if (extraArgs.length === 0) { - throw new Error('Missing command. Supported commands are: validate'); - } else if (extraArgs[0] !== 'deploy') { - throw new Error(`Invalid command: ${extraArgs[0]}. Supported commands are: deploy`); - } else if (extraArgs.length > 1) { + if (extraArgs.length !== 0) { throw new Error('The deploy command does not take any arguments, only options.'); } else { // Required options @@ -92,16 +91,6 @@ export function getFunctionArgs(parsedArgs: minimist.ParsedArgs, extraArgs: stri } } -function getAndValidateString(parsedArgs: minimist.ParsedArgs, option: string, required = false): string | undefined { - const value = parsedArgs[option]; - if (value !== undefined && value.trim().length === 0) { - throw new Error(`Invalid option: --${option} cannot be empty`); - } else if (required && value === undefined) { - throw new Error(`Missing required option: --${option}`); - } - return value; -} - function checkInvalidArgs(parsedArgs: minimist.ParsedArgs) { const invalidArgs = Object.keys(parsedArgs).filter( key => @@ -125,11 +114,3 @@ function checkInvalidArgs(parsedArgs: minimist.ParsedArgs) { throw new Error(`Invalid options: ${invalidArgs.join(', ')}`); } } - -function getNetwork(chainId: number): Network { - const network = fromChainId(chainId); - if (network === undefined) { - throw new Error(`Network ${chainId} is not supported by OpenZeppelin Defender`); - } - return network; -} \ No newline at end of file diff --git a/src/commands/propose-upgrade.ts b/src/commands/propose-upgrade.ts new file mode 100644 index 0000000..a421ea6 --- /dev/null +++ b/src/commands/propose-upgrade.ts @@ -0,0 +1,107 @@ +import minimist from 'minimist'; +import { FunctionArgs, upgradeContract } from '../internal/upgrade-contract'; +import { getDeployClient } from '../internal/client'; +import { getAndValidateString, getNetwork } from '../internal/utils'; +import { DeployClient } from '@openzeppelin/defender-sdk-deploy-client'; + +const USAGE = 'Usage: npx @openzeppelin/defender-deploy-client-cli proposeUpgrade --proxyAddress --newImplementationAddress --chainId [--proxyAdminAddress ] [--abiFile ] [--approvalProcessId ]'; +const DETAILS = ` +Proposes an upgrade using OpenZeppelin Defender. + +Required options: + --proxyAddress Address of the proxy to upgrade. + --newImplementationAddress Address of the new implementation contract. + --chainId Chain ID of the network to use. + +Additional options: + --proxyAdminAddress Address of the proxy's admin. Required if the proxy is a transparent proxy. + --abiFile Path to a JSON file that contains an "abi" entry, where its value will be used as the new implementation ABI. + --approvalProcessId The ID of the upgrade approval process. Defaults to the upgrade approval process configured for your deployment environment on Defender. +`; + +export async function proposeUpgrade(args: string[], deployClient?: DeployClient): Promise { + const { parsedArgs, extraArgs } = parseArgs(args); + + if (!help(parsedArgs, extraArgs)) { + const functionArgs = getFunctionArgs(parsedArgs, extraArgs); + const client = deployClient ?? getDeployClient(); + const upgradeResponse = await upgradeContract(functionArgs, client); + + console.log(`Upgrade proposal created.`); + console.log(`Proposal ID: ${upgradeResponse.proposalId}`); + if (upgradeResponse.externalUrl !== undefined) { + console.log(`Proposal URL: ${upgradeResponse.externalUrl}`); + } + } +} + +function parseArgs(args: string[]) { + const parsedArgs = minimist(args, { + boolean: [ + 'help', + ], + string: ['proxyAddress', 'newImplementationAddress', 'chainId', 'proxyAdminAddress', 'abiFile', 'approvalProcessId'], + alias: { h: 'help' }, + }); + const extraArgs = parsedArgs._; + return { parsedArgs, extraArgs }; +} + +function help(parsedArgs: minimist.ParsedArgs, extraArgs: string[]): boolean { + if (parsedArgs['help']) { + console.log(USAGE); + console.log(DETAILS); + return true; + } else { + return false; + } +} + +/** + * Gets and validates function arguments and options. + * @returns Function arguments + * @throws Error if any arguments or options are invalid. + */ +export function getFunctionArgs(parsedArgs: minimist.ParsedArgs, extraArgs: string[]): FunctionArgs { + if (extraArgs.length !== 0) { + throw new Error('The proposeUpgrade command does not take any arguments, only options.'); + } else { + // Required options + const proxyAddress = getAndValidateString(parsedArgs, 'proxyAddress', true)!; + const newImplementationAddress = getAndValidateString(parsedArgs, 'newImplementationAddress', true)!; + + const networkString = getAndValidateString(parsedArgs, 'chainId', true)!; + const network = getNetwork(parseInt(networkString)); + + // Additional options + const proxyAdminAddress = getAndValidateString(parsedArgs, 'proxyAdminAddress'); + const abiFile = getAndValidateString(parsedArgs, 'abiFile'); + const approvalProcessId = getAndValidateString(parsedArgs, 'approvalProcessId'); + + checkInvalidArgs(parsedArgs); + + return { proxyAddress, newImplementationAddress, network, proxyAdminAddress, abiFile, approvalProcessId }; + } +} + + + +function checkInvalidArgs(parsedArgs: minimist.ParsedArgs) { + const invalidArgs = Object.keys(parsedArgs).filter( + key => + ![ + 'help', + 'h', + '_', + 'proxyAddress', + 'newImplementationAddress', + 'chainId', + 'proxyAdminAddress', + 'abiFile', + 'approvalProcessId', + ].includes(key), + ); + if (invalidArgs.length > 0) { + throw new Error(`Invalid options: ${invalidArgs.join(', ')}`); + } +} diff --git a/src/internal/client.ts b/src/internal/client.ts new file mode 100644 index 0000000..4d24c36 --- /dev/null +++ b/src/internal/client.ts @@ -0,0 +1,13 @@ +import { DeployClient } from "@openzeppelin/defender-sdk-deploy-client"; + +export function getDeployClient(): DeployClient { + require('dotenv').config(); + const apiKey = process.env.DEFENDER_KEY as string; + const apiSecret = process.env.DEFENDER_SECRET as string; + + if (apiKey === undefined || apiSecret === undefined) { + throw new Error('DEFENDER_KEY and DEFENDER_SECRET must be set in environment variables.'); + } + + return new DeployClient({ apiKey, apiSecret }); +} \ No newline at end of file diff --git a/src/deployContract.ts b/src/internal/deploy-contract.ts similarity index 85% rename from src/deployContract.ts rename to src/internal/deploy-contract.ts index db29881..cdc849f 100644 --- a/src/deployContract.ts +++ b/src/internal/deploy-contract.ts @@ -16,17 +16,7 @@ export interface FunctionArgs { createFactoryAddress?: string; } -export async function deployContract(args: FunctionArgs) { - require('dotenv').config(); - const apiKey = process.env.DEFENDER_KEY as string; - const apiSecret = process.env.DEFENDER_SECRET as string; - - if (apiKey === undefined || apiSecret === undefined) { - throw new Error('DEFENDER_KEY and DEFENDER_SECRET must be set in environment variables.'); - } - - const client = new DeployClient({ apiKey, apiSecret }); - +export async function deployContract(args: FunctionArgs, client: DeployClient) { const buildInfoFileContents = await fs.readFile(args.artifactFile, 'utf8'); const deploymentRequest: DeployContractRequest = { diff --git a/src/internal/upgrade-contract.ts b/src/internal/upgrade-contract.ts new file mode 100644 index 0000000..262f160 --- /dev/null +++ b/src/internal/upgrade-contract.ts @@ -0,0 +1,32 @@ +import { promises as fs } from 'fs'; + +import { Network } from '@openzeppelin/defender-sdk-base-client'; +import { DeployClient, UpgradeContractRequest, UpgradeContractResponse } from '@openzeppelin/defender-sdk-deploy-client'; + +export interface FunctionArgs { + proxyAddress: string; + newImplementationAddress: string; + network: Network; + proxyAdminAddress?: string; + abiFile?: string; + approvalProcessId?: string; +} + +export async function upgradeContract(args: FunctionArgs, client: DeployClient): Promise { + let newImplementationABI: string | undefined; + if (args.abiFile !== undefined) { + const artifactObject = JSON.parse(await fs.readFile(args.abiFile, 'utf8')); + newImplementationABI = JSON.stringify(artifactObject.abi); + } + + const deploymentRequest: UpgradeContractRequest = { + proxyAddress: args.proxyAddress, + newImplementationAddress: args.newImplementationAddress, + network: args.network, + proxyAdminAddress: args.proxyAdminAddress, + newImplementationABI: newImplementationABI, + approvalProcessId: args.approvalProcessId, + }; + + return client.upgradeContract(deploymentRequest); +} \ No newline at end of file diff --git a/src/internal/utils.ts b/src/internal/utils.ts new file mode 100644 index 0000000..af2ae88 --- /dev/null +++ b/src/internal/utils.ts @@ -0,0 +1,20 @@ +import minimist from "minimist"; +import { Network, fromChainId } from "@openzeppelin/defender-sdk-base-client"; + +export function getAndValidateString(parsedArgs: minimist.ParsedArgs, option: string, required = false): string | undefined { + const value = parsedArgs[option]; + if (value !== undefined && value.trim().length === 0) { + throw new Error(`Invalid option: --${option} cannot be empty`); + } else if (required && value === undefined) { + throw new Error(`Missing required option: --${option}`); + } + return value; +} + +export function getNetwork(chainId: number): Network { + const network = fromChainId(chainId); + if (network === undefined) { + throw new Error(`Network ${chainId} is not supported by OpenZeppelin Defender`); + } + return network; +} \ No newline at end of file diff --git a/test/cli.js b/test/cli.js new file mode 100644 index 0000000..e3274e4 --- /dev/null +++ b/test/cli.js @@ -0,0 +1,22 @@ +const test = require('ava'); +const { promisify } = require('util'); +const { exec } = require('child_process'); + +const execAsync = promisify(exec); + +const CLI = 'node dist/cli.js'; + +test('help', async t => { + const output = (await execAsync(`${CLI} --help`)).stdout; + t.snapshot(output); +}); + +test('no args', async t => { + const output = (await execAsync(CLI)).stdout; + t.snapshot(output); +}); + +test('unknown command', async t => { + const error = await t.throwsAsync(execAsync(`${CLI} foo`)); + t.true(error.message.includes('Unknown command: foo')); +}); \ No newline at end of file diff --git a/test/cli.js.md b/test/cli.js.md new file mode 100644 index 0000000..ef6be54 --- /dev/null +++ b/test/cli.js.md @@ -0,0 +1,37 @@ +# Snapshot report for `test/cli.js` + +The actual snapshot is saved in `cli.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## help + +> Snapshot 1 + + `Usage: npx @openzeppelin/defender-deploy-client-cli ␊ + ␊ + Performs actions using OpenZeppelin Defender.␊ + ␊ + Available commands:␊ + deploy Deploys a contract.␊ + proposeUpgrade Proposes an upgrade.␊ + ␊ + Run 'npx @openzeppelin/defender-deploy-client-cli --help' for more information on a command.␊ + ␊ + ` + +## no args + +> Snapshot 1 + + `Usage: npx @openzeppelin/defender-deploy-client-cli ␊ + ␊ + Performs actions using OpenZeppelin Defender.␊ + ␊ + Available commands:␊ + deploy Deploys a contract.␊ + proposeUpgrade Proposes an upgrade.␊ + ␊ + Run 'npx @openzeppelin/defender-deploy-client-cli --help' for more information on a command.␊ + ␊ + ` diff --git a/test/cli.js.snap b/test/cli.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..ea38dc7668eafeece5ee3e94f12938557c979cd5 GIT binary patch literal 326 zcmV-M0lEG`RzVWFLzN00000000B+kiSmDFc8KQ5JJkv8{F0@WsjExj(?0vpFA6GX5=YWjc}DAxCist&5E$?=-<)Tc zS$g?Jc6C { + const output = (await execAsync(`${CLI} deploy --help`)).stdout; + t.snapshot(output); +}); + +test('deploy no args', async t => { + const error = await t.throwsAsync(execAsync(`${CLI} deploy`)); + t.true(error.message.includes('Missing required option: --contractName')); +}); + +const TX_HASH = '0x1'; +const DEPLOYMENT_ID = 'abc'; +const ADDRESS = '0x2'; +const FAKE_CHAIN_ID = '1'; + +test.beforeEach(t => { + const deployContractStub = sinon.stub().returns({ + txHash: TX_HASH, + deploymentId: DEPLOYMENT_ID, + address: ADDRESS, + status: 'completed' + }); + const getDeployedContractStub = sinon.stub().returns({ + txHash: TX_HASH, + deploymentId: DEPLOYMENT_ID, + address: ADDRESS, + status: 'completed' + }); + t.context.deployContractStub = deployContractStub; + + t.context.fakeDefenderClient = { + deployContract: deployContractStub, + getDeployedContract: getDeployedContractStub, + }; +}); + +test.afterEach.always(t => { + sinon.restore(); +}); + +test('deploy required args', async t => { + const args = ['--contractName', 'MyContract', '--contractPath', 'contracts/MyContract.sol', '--chainId', FAKE_CHAIN_ID, '--artifactFile', 'test/input/build-info.json']; + + await deploy(args, t.context.fakeDefenderClient); + + t.is(t.context.deployContractStub.callCount, 1); + + sinon.assert.calledWithExactly(t.context.deployContractStub, { + contractName: 'MyContract', + contractPath: 'contracts/MyContract.sol', + network: 'mainnet', + artifactPayload: '{"foo":"bar"}', + licenseType: undefined, + constructorBytecode: undefined, + verifySourceCode: true, + relayerId: undefined, + salt: undefined, + createFactoryAddress: undefined, + }); +}); + +test('deploy all args', async t => { + const args = ['--contractName', 'MyContract', '--contractPath', 'contracts/MyContract.sol', '--chainId', FAKE_CHAIN_ID, '--artifactFile', 'test/input/build-info.json', '--constructorBytecode', '0x1234', '--licenseType', 'MIT', '--verifySourceCode', 'false', '--relayerId', 'my-relayer-id', '--salt', '0x4567', '--createFactoryAddress', '0x0000000000000000000000000000000000098765']; + + await deploy(args, t.context.fakeDefenderClient); + + t.is(t.context.deployContractStub.callCount, 1); + + sinon.assert.calledWithExactly(t.context.deployContractStub, { + contractName: 'MyContract', + contractPath: 'contracts/MyContract.sol', + network: 'mainnet', + artifactPayload: '{"foo":"bar"}', + licenseType: 'MIT', + constructorBytecode: '0x1234', + verifySourceCode: false, + relayerId: 'my-relayer-id', + salt: '0x4567', + createFactoryAddress: '0x0000000000000000000000000000000000098765', + }); +}); \ No newline at end of file diff --git a/test/deploy.js.md b/test/deploy.js.md new file mode 100644 index 0000000..faa1cb3 --- /dev/null +++ b/test/deploy.js.md @@ -0,0 +1,29 @@ +# Snapshot report for `test/deploy.js` + +The actual snapshot is saved in `deploy.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## deploy help + +> Snapshot 1 + + `Usage: npx @openzeppelin/defender-deploy-client-cli deploy --contractName --contractPath --chainId --artifactFile [--constructorBytecode ] [--licenseType ] [--verifySourceCode ] [--relayerId ] [--salt ] [--createFactoryAddress ]␊ + ␊ + Deploys a contract using OpenZeppelin Defender.␊ + ␊ + Required options:␊ + --contractName Name of the contract to deploy.␊ + --contractPath Path to the contract file.␊ + --chainId Chain ID of the network to deploy to.␊ + --artifactFile Path to the build info file containing Solidity compiler input and output for the contract.␊ + ␊ + Additional options:␊ + --constructorBytecode 0x-prefixed ABI encoded byte string representing the constructor arguments. Required if the constructor has arguments.␊ + --licenseType License type for the contract. Recommended if verifying source code. Defaults to "None".␊ + --verifySourceCode Whether to verify source code on block explorers. Defaults to true.␊ + --relayerId Relayer ID to use for deployment. Defaults to the relayer configured for your deployment environment on Defender.␊ + --salt Salt to use for CREATE2 deployment. Defaults to a random salt.␊ + --createFactoryAddress Address of the CREATE2 factory to use for deployment. Defaults to the factory provided by Defender.␊ + ␊ + ` diff --git a/test/deploy.js.snap b/test/deploy.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..e6ea8e02626be2175fc5a9e0828276f1477a89d9 GIT binary patch literal 783 zcmV+q1MvJoRzV#7T5MN@mf*i{8hl zU#)AwQxqDLx+IrLI0~k5;4D|DcwWb{QYnra1cJn$IV&_e4}}G*QCX5vF!AO=WN9`g z%?bl2u{TNFf2xof95YL)_OeW&);9n9zb1HK%E4)i%WUJs{2jO0opgE<89fX|3 zX@mDQ>^viDh0xzp5NN(jb$Q2tz8ubDWZ&U(QSl-JE^^thX}D1`+rU^BJmaROvvo-} zMQ&wffC=hdW$b4z)lNm!UXqO4zOrJk!4FT`aGnICnQzdhxfT?A*CYv6>t=v4{b6YXpacyYcJLR`ROq>;rHSZNqxu{iZ zJ7`Thw-=V5ZgvA~6$3kqtSXGQ5*$sXz$2IX!8HQ-v_i_JY?SltqJk9A++Tnj8VH3- z?`2TFO|IFZhpHL N(QlV~t?_aO0012ta3=r& literal 0 HcmV?d00001 diff --git a/test/input/MyContract.json b/test/input/MyContract.json new file mode 100644 index 0000000..f5854bd --- /dev/null +++ b/test/input/MyContract.json @@ -0,0 +1,8 @@ +{ + "abi": [ + { + "type": "function", + "name": "hello" + } + ] +} \ No newline at end of file diff --git a/test/input/build-info.json b/test/input/build-info.json new file mode 100644 index 0000000..9f5dd4e --- /dev/null +++ b/test/input/build-info.json @@ -0,0 +1 @@ +{"foo":"bar"} \ No newline at end of file diff --git a/test/propose-upgrade.js b/test/propose-upgrade.js new file mode 100644 index 0000000..727e8e9 --- /dev/null +++ b/test/propose-upgrade.js @@ -0,0 +1,77 @@ +const test = require('ava'); +const sinon = require('sinon'); +const { promisify } = require('util'); +const { exec } = require('child_process'); +const { proposeUpgrade } = require('../dist/commands/propose-upgrade'); + +const execAsync = promisify(exec); + +const CLI = 'node dist/cli.js'; + +test('deploy help', async t => { + const output = (await execAsync(`${CLI} proposeUpgrade --help`)).stdout; + t.snapshot(output); +}); + +test('deploy no args', async t => { + const error = await t.throwsAsync(execAsync(`${CLI} proposeUpgrade`)); + t.true(error.message.includes('Missing required option: --proxyAddress')); +}); + +const PROXY_ADDRESS = '0x123'; +const NEW_IMPLEMENTATION_ADDRESS = '0x456'; +const FAKE_CHAIN_ID = '1'; + +const PROXY_ADMIN_ADDRESS = '0x789'; +const APPROVAL_PROCESS_ID = 'my-approval-process-id'; + +const ABI_FILE = 'test/input/MyContract.json' + +test.beforeEach(t => { + const upgradeContractStub = sinon.stub().returns({ + proposalId: 'my-proposal-id', + }); + t.context.upgradeContractStub = upgradeContractStub; + + t.context.fakeDefenderClient = { + upgradeContract: upgradeContractStub, + }; +}); + +test.afterEach.always(t => { + sinon.restore(); +}); + +test('proposeUpgrade required args', async t => { + const args = ['--proxyAddress', PROXY_ADDRESS, '--newImplementationAddress', NEW_IMPLEMENTATION_ADDRESS, '--chainId', FAKE_CHAIN_ID]; + + await proposeUpgrade(args, t.context.fakeDefenderClient); + + t.is(t.context.upgradeContractStub.callCount, 1); + + sinon.assert.calledWithExactly(t.context.upgradeContractStub, { + proxyAddress: PROXY_ADDRESS, + newImplementationAddress: NEW_IMPLEMENTATION_ADDRESS, + network: 'mainnet', + proxyAdminAddress: undefined, + newImplementationABI: undefined, + approvalProcessId: undefined, + }); +}); + +test('proposeUpgrade all args', async t => { + const args = ['--proxyAddress', PROXY_ADDRESS, '--newImplementationAddress', NEW_IMPLEMENTATION_ADDRESS, '--chainId', FAKE_CHAIN_ID, '--proxyAdminAddress', PROXY_ADMIN_ADDRESS, '--abiFile', ABI_FILE, '--approvalProcessId', APPROVAL_PROCESS_ID]; + + await proposeUpgrade(args, t.context.fakeDefenderClient); + + t.is(t.context.upgradeContractStub.callCount, 1); + + sinon.assert.calledWithExactly(t.context.upgradeContractStub, { + proxyAddress: PROXY_ADDRESS, + newImplementationAddress: NEW_IMPLEMENTATION_ADDRESS, + network: 'mainnet', + proxyAdminAddress: PROXY_ADMIN_ADDRESS, + newImplementationABI: '[{"type":"function","name":"hello"}]', + approvalProcessId: APPROVAL_PROCESS_ID, + }); +}); \ No newline at end of file diff --git a/test/propose-upgrade.js.md b/test/propose-upgrade.js.md new file mode 100644 index 0000000..08eab9c --- /dev/null +++ b/test/propose-upgrade.js.md @@ -0,0 +1,25 @@ +# Snapshot report for `test/propose-upgrade.js` + +The actual snapshot is saved in `propose-upgrade.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## deploy help + +> Snapshot 1 + + `Usage: npx @openzeppelin/defender-deploy-client-cli proposeUpgrade --proxyAddress --newImplementationAddress --chainId [--proxyAdminAddress ] [--abiFile ] [--approvalProcessId ]␊ + ␊ + Proposes an upgrade using OpenZeppelin Defender.␊ + ␊ + Required options:␊ + --proxyAddress Address of the proxy to upgrade.␊ + --newImplementationAddress Address of the new implementation contract.␊ + --chainId Chain ID of the network to use.␊ + ␊ + Additional options:␊ + --proxyAdminAddress Address of the proxy's admin. Required if the proxy is a transparent proxy.␊ + --abiFile Path to a JSON file that contains an "abi" entry, where its value will be used as the new implementation ABI.␊ + --approvalProcessId The ID of the upgrade approval process. Defaults to the upgrade approval process configured for your deployment environment on Defender.␊ + ␊ + ` diff --git a/test/propose-upgrade.js.snap b/test/propose-upgrade.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..4493b0c8fe0fbbe0ab4ca8942d6afd6f80f9221a GIT binary patch literal 605 zcmV-j0;2svRzVl;3XCFciiM3?b!?H#pi2H?+H-riswBEND%mGzA8lBDZlG ztFCQqXI;75ql_ozIO)GZVL)zd`5gbw`92@zzL;=1otwMw)H2KIJg17w6_k`KW&}~D zl-Atc=Q*+D>L`2GdeL&fdFEe6x-CO z_c03x@nvt&i&8%ghtdAMX-Sv}b2#f>_+gZV!8v@{7F#g!OT|IByMOI+$%OS8r$`t^ zY2tU&%umv=?>7DLvX{kvda=$_$h{#v)^dtWS9Kh}OZ=dh`7!c8_?HWaS%57m)X8Awredc? z{GY);vylSq0S2a0SWTw3DRbaG_P(x*LKy6Db}99|Zq{I<9+qQ{O87wo9(&NkUc5s8 z-PI0k-DUe$F^mIh5JnM=x|-ug&tFXkKum1uR1kPS8b(mKE4C!Ib_X@r6L5^MW55D> z^%|BX)fAXDfD=_wSTfFG;>H8pBj$H*`)|W_WB)dc04V}@&Tpo1gK?T2r{ji^R2=(4 rIe*G@(h4@KoSlW#u#%OA=11V#MV&WHOHuFTgBkw;jZDkyj|2b!#jqsr literal 0 HcmV?d00001 diff --git a/yarn.lock b/yarn.lock index 63e2ed9..1a8868c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -296,6 +296,41 @@ resolved "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-1.0.0.tgz" integrity sha512-rUV5WyJrJLoloD4NDN1V1+LDMDWOa4OTsT4yYJwQNpTU6FWxkxHpL7eu4w+DmiH8x/EAM1otkPE1+LaspIbplw== +"@sinonjs/commons@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3" + integrity sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg== + dependencies: + type-detect "4.0.8" + +"@sinonjs/commons@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^11.2.2": + version "11.2.2" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz#50063cc3574f4a27bd8453180a04171c85cc9699" + integrity sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw== + dependencies: + "@sinonjs/commons" "^3.0.0" + +"@sinonjs/samsam@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-8.0.0.tgz#0d488c91efb3fa1442e26abea81759dfc8b5ac60" + integrity sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew== + dependencies: + "@sinonjs/commons" "^2.0.0" + lodash.get "^4.4.2" + type-detect "^4.0.8" + +"@sinonjs/text-encoding@^0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918" + integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ== + "@smithy/types@^2.7.0": version "2.7.0" resolved "https://registry.yarnpkg.com/@smithy/types/-/types-2.7.0.tgz#6ed9ba5bff7c4d28c980cff967e6d8456840a4f3" @@ -724,6 +759,11 @@ detect-libc@^2.0.0: resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz" integrity sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw== +diff@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" + integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== + dotenv@^16.3.1: version "16.3.1" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" @@ -955,6 +995,11 @@ graceful-fs@^4.2.9: resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + has-unicode@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz" @@ -1120,11 +1165,21 @@ js-yaml@^3.14.1: argparse "^1.0.7" esprima "^4.0.0" +just-extend@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-6.2.0.tgz#b816abfb3d67ee860482e7401564672558163947" + integrity sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw== + load-json-file@^7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/load-json-file/-/load-json-file-7.0.1.tgz" integrity sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ== +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + lodash@^4.17.15, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" @@ -1269,6 +1324,17 @@ ms@^2.1.3: resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +nise@^5.1.5: + version "5.1.9" + resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.9.tgz#0cb73b5e4499d738231a473cd89bd8afbb618139" + integrity sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww== + dependencies: + "@sinonjs/commons" "^3.0.0" + "@sinonjs/fake-timers" "^11.2.2" + "@sinonjs/text-encoding" "^0.7.2" + just-extend "^6.2.0" + path-to-regexp "^6.2.1" + node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" @@ -1351,6 +1417,11 @@ path-scurry@^1.10.1: lru-cache "^9.1.1 || ^10.0.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" +path-to-regexp@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.1.tgz#d54934d6798eb9e5ef14e7af7962c945906918e5" + integrity sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw== + path-type@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz" @@ -1498,6 +1569,18 @@ signal-exit@^4.0.1: resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +sinon@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-17.0.1.tgz#26b8ef719261bf8df43f925924cccc96748e407a" + integrity sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g== + dependencies: + "@sinonjs/commons" "^3.0.0" + "@sinonjs/fake-timers" "^11.2.2" + "@sinonjs/samsam" "^8.0.0" + diff "^5.1.0" + nise "^5.1.5" + supports-color "^7.2.0" + slash@^5.1.0: version "5.1.0" resolved "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz" @@ -1581,6 +1664,13 @@ supertap@^3.0.1: serialize-error "^7.0.1" strip-ansi "^7.0.1" +supports-color@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + tar@^6.1.11: version "6.2.0" resolved "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz" @@ -1625,6 +1715,11 @@ tslib@^2.3.1, tslib@^2.5.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== +type-detect@4.0.8, type-detect@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + type-fest@^0.13.1: version "0.13.1" resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz"