diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..7101c6a --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,70 @@ +name: Deploy + +on: + workflow_dispatch: + inputs: + network: + type: choice + description: Network + options: + - mainnet + - sepolia + - base + - base-sepolia + contract: + description: Contract to deploy + required: true + +jobs: + deploy-contract: + name: Deploy Contract + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: "16" + + - name: Install packages + run: npm install + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Set RPC URL for mainnet + if: ${{ github.event.inputs.network == 'mainnet' }} + run: echo "RPC_URL=${{ secrets.MAINNET_RPC_URL }}" >> $GITHUB_ENV + + - name: Set RPC URL for sepolia + if: ${{ github.event.inputs.network == 'sepolia' }} + run: echo "RPC_URL=${{ secrets.SEPOLIA_RPC_URL }}" >> $GITHUB_ENV + + - name: Set RPC URL for base + if: ${{ github.event.inputs.network == 'base' }} + run: echo "RPC_URL=${{ secrets.BASE_RPC_URL }}" >> $GITHUB_ENV + + - name: Set RPC URL for base-sepolia + if: ${{ github.event.inputs.network == 'base-sepolia' }} + run: echo "RPC_URL=${{ secrets.BASE_SEPOLIA_RPC_URL }}" >> $GITHUB_ENV + + - name: Run Deploy + run: | + npx ts-node script/deploy.ts deploy ${{ github.event.inputs.contract }} --rpc ${{ env.RPC_URL }} --pk ${{ secrets.private_key }} + env: + NETWORK: ${{ github.event.inputs.network }} + CONTRACT: ${{ github.event.inputs.contract }} + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + RPC_URL: ${{ env.RPC_URL }} + BASESCAN_API_KEY: ${{ secrets.BASESCAN_API_KEY }} + + - name: Prettier Fix + run: npm run prettier:write + + - name: Commit and push changes + run: | + git config --global user.name 'GitHub Actions Bot' + git config --global user.email '<>' + git commit -a -m "CI deployment of ${{ github.event.inputs.contract }}" + git push diff --git a/deployments/8453.json b/deployments/8453.json new file mode 100644 index 0000000..3b3b087 --- /dev/null +++ b/deployments/8453.json @@ -0,0 +1,11 @@ +{ + "contracts": { + "Dropper": { + "deploys": [ + { "deployedArgs": "0x", "version": "1.0.0", "address": "0xf7dfc33c76f84a5c852f5366598e67cce91bad53" } + ], + "constructorArgs": [] + } + }, + "constants": {} +} diff --git a/deployments/84532.json b/deployments/84532.json index 9452751..4869b74 100644 --- a/deployments/84532.json +++ b/deployments/84532.json @@ -1,8 +1,11 @@ { - "Dropper": [ - { - "version": "1.0.0", - "address": "0x2871e49a08acee842c8f225be7bff9cc311b9f43" + "contracts": { + "Dropper": { + "deploys": [ + { "deployedArgs": "0x", "version": "1.0.0", "address": "0x680168736298451b945F1Dc5D952408C102E7De7" } + ], + "constructorArgs": [] } - ] + }, + "constants": {} } diff --git a/package.json b/package.json index 4bebc8f..d2e6400 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "url": "https://github.com/PartyDAO" }, "devDependencies": { + "@types/node": "^20.12.10", + "@types/yargs": "^17.0.32", "prettier": "^3.0.0", "prettier-plugin-solidity": "^1.3.1", "solhint": "^3.6.2" @@ -30,5 +32,11 @@ "test": "forge test", "test:coverage": "forge coverage", "test:coverage:report": "forge coverage --report lcov && genhtml lcov.info --branch-coverage --output-dir coverage" + }, + "dependencies": { + "ethers": "^6.12.1", + "ts-node": "^10.9.2", + "typescript": "^5.4.5", + "yargs": "^17.7.2" } } diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol deleted file mode 100644 index 51ae07a..0000000 --- a/script/Deploy.s.sol +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity >=0.8.25; - -import { Script } from "forge-std/src/Script.sol"; -import { Test } from "forge-std/src/Test.sol"; -import { Dropper } from "src/Dropper.sol"; -import { Strings } from "openzeppelin-contracts/contracts/utils/Strings.sol"; - -// Deployment files of the following format: { contractName : { version: address } } - -contract DeployScript is Script, Test { - function run() external { - uint256 chainId = block.chainid; - string memory dropperVersion = _getDropperVersion(); - _runDeploymentOfVersion(chainId, dropperVersion); - } - - struct Deployment { - address addr; - string version; - } - - function _runDeploymentOfVersion(uint256 chainId, string memory contractVersion) internal { - string memory serializationKey = "deployments"; - string memory filePath = string(abi.encodePacked("deployments/", Strings.toString(chainId), ".json")); - bool fileExists = vm.exists(filePath); - string memory fileContent; - Deployment[] memory existingDeployments; - - if (fileExists) { - fileContent = vm.readFile(filePath); - string memory keyToSearch = string(abi.encodePacked("$.Dropper[?(@.version ==\"", contractVersion, "\")]")); - if (vm.keyExistsJson(fileContent, keyToSearch)) { - require(false, "Contract already deployed"); - } - existingDeployments = abi.decode(vm.parseJson(fileContent, "$.Dropper"), (Deployment[])); - } - - address newContractAddress = _deploy(); - - string[] memory serializedDeployments = new string[](existingDeployments.length + 1); - for (uint256 i = 0; i < existingDeployments.length; i++) { - Deployment memory deployment = existingDeployments[i]; - serializedDeployments[i] = _serializeDeployment(deployment); - } - - serializedDeployments[existingDeployments.length] = - _serializeDeployment(Deployment(newContractAddress, contractVersion)); - - string memory newJson = string(abi.encodePacked("[", serializedDeployments[0])); - - for (uint256 i = 1; i < serializedDeployments.length; i++) { - newJson = string(abi.encodePacked(newJson, ",", serializedDeployments[i])); - } - - newJson = string(abi.encodePacked(newJson, "]")); - - string memory finalJson = vm.serializeString(serializationKey, "Dropper", newJson); - vm.writeJson(finalJson, filePath); - } - - function _serializeDeployment(Deployment memory deployment) internal pure returns (string memory) { - return string( - abi.encodePacked( - "{\"version\":\"", deployment.version, "\",\"address\":\"", Strings.toHexString(deployment.addr), "\"}" - ) - ); - } - - function _deploy() internal returns (address) { - vm.broadcast(); - Dropper dropper = new Dropper(); - - return address(dropper); - } - - function _getDropperVersion() internal returns (string memory) { - Dropper dropper = new Dropper(); - return dropper.VERSION(); - } -} diff --git a/script/deploy.ts b/script/deploy.ts new file mode 100644 index 0000000..9325023 --- /dev/null +++ b/script/deploy.ts @@ -0,0 +1,292 @@ +import fs from "fs"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { ChildProcessWithoutNullStreams, exec, execSync, spawn } from "child_process"; +import { ethers } from "ethers"; + +yargs(hideBin(process.argv)) + .usage("$0 [args]") + .command( + "deploy ", + "deploy the given contract", + (yargs) => { + return yargs + .positional("contract", { + describe: "contract to deploy", + type: "string", + demandOption: "true", + }) + .describe("rpc", "The URL of the RPC to use for deployment") + .describe("pk", "The private key to use for deployment") + .array("constructor-args") + .string("constructor-args") + .string("pk") + .string("rpc") + .demandOption(["rpc", "pk"]); + }, + (argv) => { + runDeploy(argv.contract, argv.rpc, argv.pk, argv["constructor-args"]); + }, + ) + .command( + "init ", + "initialize the deployment file for a given network", + (yargs) => { + return yargs.positional("chainId", { + describe: "network id to initialize for", + type: "string", + demandOption: "true", + }); + }, + (argv) => { + initProject(argv.chainId); + }, + ) + .parse(); + +async function runDeploy(contract: string, rpcUrl: any, privateKey: any, constructorArgs: any) { + const contracts = getProjectContracts(); + if (!contracts.includes(contract)) { + throw new Error(`Contract ${contract} not found in project`); + } + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const chainId = (await provider.getNetwork()).chainId.toString(); + + // If no constructor args are given, try to resolve from deployment file + if (!constructorArgs || constructorArgs.length == 0) { + constructorArgs = resolveConstructorArgs(contract, chainId); + } + + const encodedConstructorArgs = encodeConstructorArgs(contract, constructorArgs); + let newDeploy: Deploy = { deployedArgs: encodedConstructorArgs } as Deploy; + newDeploy.version = await getUndeployedContractVersion(contract); + + validateDeploy(contract, newDeploy, chainId); + + console.log("Deploying contract..."); + const createCommand = `forge create ${contract} --private-key ${privateKey} --rpc-url ${rpcUrl} --verify ${ + !!constructorArgs && constructorArgs.length > 0 ? "--constructor-args " + constructorArgs.join(" ") : "" + }`; + + const out = await execSync(createCommand); + const lines = out.toString().split("\n"); + for (const line of lines) { + if (line.startsWith("Deployed to: ")) { + // Get the address + newDeploy.address = line.split("Deployed to: ")[1]; + } + } + + console.log(`Contract ${contract} deployed to ${newDeploy.address} with version ${newDeploy.version}`); + + writeDeploy(contract, newDeploy, chainId); +} + +/** + * Resolves the constructor arguments for a contract. Must be other contracts in the repo or constants in the deployment file. + * @param contractName Contract to resolve args for + * @param chainId Chain to deploy to + */ +function resolveConstructorArgs(contractName: string, chainId: string): string[] { + if (!fs.existsSync(`deployments/${chainId}.json`)) { + throw new Error(`Deployment file for network ${chainId} does not exist`); + } + const deploymentFile: DeploymentFile = JSON.parse(fs.readFileSync(`deployments/${chainId}.json`, "utf-8")); + + if (!(contractName in deploymentFile.contracts)) { + throw new Error(`Contract ${contractName} does not exist in project`); + } + + const args = deploymentFile.contracts[contractName].constructorArgs; + + let resolvedArgs: string[] = new Array(args.length); + + for (let i = 0; i < args.length; i++) { + if (args[i] in deploymentFile.contracts) { + const contractObj = deploymentFile.contracts[args[i]]; + if (contractObj.deploys.length == 0) throw new Error(`Contract ${args[i]} doesn't have any deploy`); + resolvedArgs[i] = contractObj.deploys.at(-1)!.address; + } else { + // Must be in constants or revert + if (args[i] in deploymentFile.constants) { + resolvedArgs[i] = deploymentFile.constants[args[i]]; + } else { + throw new Error(`Argument ${args[i]} not found in deployment file or constants`); + } + } + } + + return resolvedArgs; +} + +/** + * Validates a deploy. Should be called prior to writing anything to chain. + * @param contract Name of the contract to be deployed + * @param deploy Deployments specifications to be used + * @param chainId Chain to deploy to + */ +function validateDeploy(contract: string, deploy: Deploy, chainId: string) { + // First check if deployment file exists + if (!fs.existsSync(`deployments/${chainId}.json`)) { + initProject(chainId); + } + const existingDeployments = JSON.parse(fs.readFileSync(`deployments/${chainId}.json`, "utf-8")); + + if ( + !!existingDeployments.contracts[contract].deploys.find( + (d: Deploy) => d.version == deploy.version && d.deployedArgs == deploy.deployedArgs, + ) + ) { + throw new Error( + `Contract ${contract} with version ${deploy.version} and deployed args ${deploy.deployedArgs || ""} already deployed`, + ); + } +} + +/** + * Writes a new deploy to the deployment file + * @param contract Name of the contract deployed + * @param deploy Deployments specifications + * @param chainId The chain deployed to + */ +function writeDeploy(contract: string, deploy: Deploy, chainId: string) { + // First check if deployment file exists + if (!fs.existsSync(`deployments/${chainId}.json`)) { + initProject(chainId); + } + const existingDeployments = JSON.parse(fs.readFileSync(`deployments/${chainId}.json`, "utf-8")); + existingDeployments.contracts[contract].deploys.push(deploy); + fs.writeFileSync(`deployments/${chainId}.json`, JSON.stringify(existingDeployments)); +} + +/** + * Launches a local anvil instance using the `mnemonic-seed` 123 + * @returns Returns the child process. Must be killed. + */ +async function launchAnvil(): Promise { + var anvil = spawn("anvil", ["--mnemonic-seed-unsafe", "123"]); + return new Promise((resolve) => { + anvil.stdout.on("data", function (data) { + if (data.includes("Listening")) { + resolve(anvil); + } + }); + }); +} + +/** + * Gets the version of an undeployed contract via deploying to a local network. + * @param contractName Name of the contract in the repo + * @returns + */ +async function getUndeployedContractVersion(contractName: string): Promise { + const anvil = await launchAnvil(); + + const createCommand = `forge create ${contractName} --private-key 0x78427d179c2c0f8467881bc37f9453a99854977507ca53ff65e1c875208a4a03 --rpc-url "127.0.0.1:8545"`; + let addr = ""; + + const out = await execSync(createCommand); + const lines = out.toString().split("\n"); + for (const line of lines) { + if (line.startsWith("Deployed to: ")) { + // Get the address + addr = line.split("Deployed to: ")[1]; + } + } + + const res = await getContractVersion(addr, "http://127.0.0.1:8545"); + anvil.kill(); + + return res; +} + +/** + * Fetches the version of the given contract by calling `VERSION` + * @param contractAddress Address the contract is deployed to + * @param rpcUrl RPC to connect to the network where the contract is deployed + * @returns + */ +async function getContractVersion(contractAddress: string, rpcUrl: string): Promise { + const provider = new ethers.JsonRpcProvider(rpcUrl); + const versionRes = await provider.call({ to: contractAddress, data: "0xffa1ad74" /* Version function */ }); + return ethers.AbiCoder.defaultAbiCoder().decode(["string"], versionRes)[0]; +} + +function encodeConstructorArgs(contractName: string, args: string[] | undefined): string { + if (!!args) { + const contractABI = JSON.parse(fs.readFileSync(`out/${contractName}.sol/${contractName}.json`, "utf-8")).abi; + const contractInterface = new ethers.Interface(contractABI); + return contractInterface.encodeDeploy(args); + } + return ""; +} + +type Deploy = { + version: string; + address: string; + deployedArgs: string; +}; +type Contract = { + deploys: Deploy[]; + constructorArgs: string[]; +}; +type DeploymentFile = { + contracts: { [key: string]: Contract }; + constants: { [key: string]: string }; +}; + +/** + * Initialize the deployment file for a given network + * @param chainId + */ +function initProject(chainId: string) { + console.log(`Initializing project for network ${chainId}...`); + + if (fs.existsSync(`deployments/${chainId}.json`)) { + throw new Error(`Deployment file for network ${chainId} already exists`); + } + + let fileToStore: DeploymentFile = { + contracts: {}, + constants: {}, + }; + const contracts = getProjectContracts(); + contracts.map((contract) => { + fileToStore.contracts[contract] = { + deploys: [], + constructorArgs: [], + }; + }); + + if (!fs.existsSync("deployments")) { + fs.mkdirSync("deployments"); + } + + fs.writeFileSync(`deployments/${chainId}.json`, JSON.stringify(fileToStore)); +} + +/** + * Gets all the deployable contracts in the project + * @returns An array of contract names not including the path or extension + */ +function getProjectContracts(): string[] { + console.log("Building project..."); + execSync("forge build"); + const buildCache = JSON.parse(fs.readFileSync("cache/solidity-files-cache.json", "utf-8")); + // Get files in src directory + const filesOfInterest = Object.keys(buildCache.files).filter((file: string) => file.startsWith("src/")); + + // Get contracts that have bytecode + let deployableContracts: string[] = []; + for (const file of filesOfInterest) { + const fileName = file.split("/").pop()!; + const buildOutput = JSON.parse(fs.readFileSync(`out/${fileName}/${fileName.split(".")[0]}.json`, "utf-8")); + // Only consider contracts that are deployable + if (buildOutput.bytecode.object !== "0x") { + deployableContracts.push(fileName.split(".")[0]); + } + } + + return deployableContracts; +}