├── .changeset ├── README.md └── config.json ├── .env.example ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── actions │ └── install-dependencies │ │ └── action.yml └── workflows │ ├── on-pull-request.yml │ ├── on-push-to-main.yml │ └── verify.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── biome.json ├── bun.lockb ├── package.json ├── src ├── action │ ├── computeAddress.ts │ ├── deployContracts.ts │ ├── findDeployment.ts │ ├── getDeployerAddress.ts │ ├── index.ts │ └── verifyContracts.ts ├── clients │ ├── createKernelClient.ts │ └── index.ts ├── command │ └── index.ts ├── config.ts ├── constant.ts ├── index.ts └── utils │ ├── file.ts │ ├── format.ts │ ├── index.ts │ └── validate.ts ├── test └── deployContract.test.ts └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Your deployer private key 2 | 3 | PRIVATE_KEY="PRIVATE_KEY" 4 | 5 | ZERODEV_API_KEY="" 6 | ZERODEV_PROJECT_ID="" 7 | 8 | ETHERSCAN_API_KEY="" -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/actions/install-dependencies/action.yml: -------------------------------------------------------------------------------- 1 | name: "Install dependencies" 2 | description: "Prepare repository and all dependencies" 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - name: Set up Bun 8 | uses: oven-sh/setup-bun@v1 9 | 10 | - name: Install dependencies 11 | shell: bash 12 | run: bun install --ignore-scripts -------------------------------------------------------------------------------- /.github/workflows/on-pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | on: 3 | pull_request: 4 | types: [opened, reopened, synchronize, ready_for_review] 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 8 | cancel-in-progress: true 9 | 10 | jobs: 11 | verify: 12 | name: Verify 13 | uses: ./.github/workflows/verify.yml 14 | secrets: inherit 15 | 16 | size: 17 | name: Size 18 | runs-on: ubuntu-latest 19 | timeout-minutes: 5 20 | 21 | steps: 22 | - name: Clone repository 23 | uses: actions/checkout@v3 24 | 25 | - name: Install dependencies 26 | uses: ./.github/actions/install-dependencies 27 | 28 | - name: Report bundle size 29 | uses: andresz1/size-limit-action@master 30 | with: 31 | github_token: ${{ secrets.GITHUB_TOKEN }} 32 | package_manager: bun 33 | -------------------------------------------------------------------------------- /.github/workflows/on-push-to-main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | on: 3 | push: 4 | branches: [main] 5 | workflow_dispatch: 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | verify: 13 | name: Verify 14 | uses: ./.github/workflows/verify.yml 15 | secrets: inherit 16 | 17 | changesets: 18 | name: Changesets 19 | runs-on: ubuntu-latest 20 | timeout-minutes: 5 21 | 22 | steps: 23 | - name: Clone repository 24 | uses: actions/checkout@v3 25 | with: 26 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 27 | fetch-depth: 0 28 | 29 | - name: Install dependencies 30 | uses: ./.github/actions/install-dependencies 31 | 32 | - name: Create Version Pull Request 33 | uses: changesets/action@v1 34 | with: 35 | version: bun run changeset:version 36 | commit: 'chore: version package' 37 | title: 'chore: version package' 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | release: 42 | name: Release 43 | needs: verify 44 | runs-on: ubuntu-latest 45 | timeout-minutes: 5 46 | 47 | steps: 48 | - name: Clone repository 49 | uses: actions/checkout@v3 50 | 51 | - name: Install dependencies 52 | uses: ./.github/actions/install-dependencies 53 | 54 | - name: Publish to NPM 55 | uses: changesets/action@v1 56 | with: 57 | publish: bun run changeset:release 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | name: Verify 2 | on: 3 | workflow_call: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | lint: 8 | name: Lint 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 5 11 | 12 | steps: 13 | - name: Clone repository 14 | uses: actions/checkout@v3 15 | 16 | - name: Install dependencies 17 | uses: ./.github/actions/install-dependencies 18 | 19 | - name: Lint code 20 | run: bun format && bun lint:fix 21 | 22 | - uses: stefanzweifel/git-auto-commit-action@v4 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | with: 26 | commit_message: 'chore: format' 27 | commit_user_name: 'github-actions[bot]' 28 | commit_user_email: 'github-actions[bot]@users.noreply.github.com' 29 | 30 | build: 31 | name: Build 32 | needs: lint 33 | runs-on: ubuntu-latest 34 | timeout-minutes: 5 35 | 36 | steps: 37 | - name: Clone repository 38 | uses: actions/checkout@v3 39 | 40 | - name: Install dependencies 41 | uses: ./.github/actions/install-dependencies 42 | 43 | - name: Build 44 | run: bun run build -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | dist 4 | bytecode 5 | session-key.txt 6 | log 7 | 8 | # for testing contract verification 9 | foundry.toml 10 | out/ 11 | cache/ 12 | src/HelloWorld.sol -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @zerodev/orchestra 2 | 3 | ## 0.2.0 4 | 5 | ### Minor Changes 6 | 7 | - update to use zerodev v3 api 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 ZeroDev, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Orchestra 2 | 3 | Orchestra is a CLI for deterministically deploying contracts to multiple chains, even if you don't have gas tokens for each chain. It's able to do that thanks to ERC-4337 paymasters. Orchestra is built on [ZeroDev](https://docs.zerodev.app/). 4 | 5 | ## Installation 6 | 7 | 1. `npm install -g @zerodev/orchestra` 8 | 2. Create a `.env` file based on `.env.example` 9 | - You can acquire the project IDs from [the ZeroDev dashboard](https://dashboard.zerodev.app/) 10 | - Use [this link](https://dashboard.zerodev.app/account/api-key) to get the API key for the team 11 | 3. Test the installation by running `zerodev -h` 12 | 13 | ## Usage 14 | 15 | ### Deploying a Contract 16 | 17 | - Before deployment, make sure that you have the bytecode of the contract you want to deploy 18 | - You can deploy a contract to multiple chains with the following command 19 | 20 | ``` 21 | zerodev deploy [options] 22 | ``` 23 | 24 | - For example, if you want to deploy a contract to the Optimism Sepolia and Polygon Mumbai testnet with `bytecode` file and zero bytes `salt`, you can run the following command 25 | 26 | ``` 27 | zerodev deploy -f ./bytecode -s 0 -c optimism-sepolia,polygon-mumbai 28 | ``` 29 | 30 | - if you want to deploy to all testnets or all mainnets, use `-t` `--testnet-all` / `-m` `--mainnet-all` flag instead of `-c` `--chain` flag 31 | 32 | ``` 33 | zerodev deploy -f ./bytecode -s 0 -t 34 | ``` 35 | 36 | ``` 37 | zerodev deploy -f ./bytecode -s 0 -m 38 | ``` 39 | 40 | - After deployment, you can see the deployed contract address and its user operation hash with jiffy scan link. 41 | 42 | ### Available Commands 43 | 44 | All commands should be prefixed with `zerodev` 45 | 46 | - `-h`, `--help`: Show help 47 | - `chains`: Show the list of available chains 48 | - `compute-address [options]`: Compute the address to be deployed 49 | - `-f, --file `: file path of bytecode to deploy 50 | - `-b, --bytecode `: bytecode to deploy, should have constructor arguments encoded 51 | - `-s, --salt `: salt to use for deployment, this can be a full 32-byte hex string or a shorter numeric representation that will be converted to a 32-byte hex string. 52 | - `get-deployer-address`: Get the deployer's address 53 | - `deploy [options]`: Deploy contracts deterministically using CREATE2, in order of the chains specified 54 | - `-f, --file `: file path of bytecode to deploy 55 | - `-b, --bytecode `: bytecode to deploy, should have constructor arguments encoded 56 | - `-s, --salt `: salt to use for deployment, this can be a full 32-byte hex string or a shorter numeric representation that will be converted to a 32-byte hex string. 57 | - `-t, --testnet-all`: deploy to all testnets 58 | - `-m, --mainnet-all`: deploy to all mainnets 59 | - `-c, --chains [CHAINS]`: list of chains to deploy, with `all` selected by default 60 | - `-e, --expected-address
`: expected address to confirm 61 | - `-v, --verify `: contract name to be verified 62 | - `-g, --call-gas-limit `: gas limit for the call 63 | - `check-deployment [options]`: Check if the contract has been deployed on the specified chains 64 | - `-f, --file `: file path of bytecode used for deployment 65 | - `-b, --bytecode `: bytecode to deploy, should have constructor arguments encoded 66 | - `-s, --salt `: salt to use for deployment, this can be a full 32-byte hex string or a shorter numeric representation that will be converted to a 32-byte hex string. 67 | - `-c, --chains [CHAINS]`: list of chains to check, with `all` selected by default 68 | - `-t, --testnet-all`: check all testnets 69 | - `-m, --mainnet-all`: check all mainnets 70 | - `clear-log`: Clear the log files 71 | - `generate-salt`: Generate a random 32 bytes salt, or convert the input to salt 72 | - `-i, --input `: input to convert to 32 bytes salt(ex. if input is given as `0`, returns `0x0000000000000000000000000000000000000000000000000000000000000000`) 73 | - `help [command]`: display help for command 74 | 75 | ## Supported Networks 76 | 77 | Orchestra supports all network supported by ZeroDev. Check details [here](https://docs.zerodev.app/supported-networks) 78 | 79 | ## Help Wanted 80 | 81 | - Orchestra can in principle run on any AA infra, but since ERC-4337 paymasters tend to be incompatible across vendors, currently Orchestra only support ZeroDev paymasters. We welcome PRs to add support for other AA infra providers. To do so, you would first add support for other infra providers to [Kernel.js](https://github.com/zerodevapp/kernel), which Orchestra is built on top of. Feel free to [reach out to us](https://discord.gg/KS9MRaTSjx) if you need help with this task. 82 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.0.0/schema.json", 3 | "files": { 4 | "ignore": [ 5 | "node_modules", 6 | "**/node_modules", 7 | "cache", 8 | "coverage", 9 | "tsconfig.json", 10 | "tsconfig.*.json", 11 | "_cjs", 12 | "_esm", 13 | "_types", 14 | "bun.lockb", 15 | "dist" 16 | ] 17 | }, 18 | "organizeImports": { 19 | "enabled": true 20 | }, 21 | "linter": { 22 | "enabled": true, 23 | "rules": { 24 | "recommended": true, 25 | "suspicious": { 26 | "noExplicitAny": "warn" 27 | }, 28 | "style": { 29 | "noUnusedTemplateLiteral": "warn" 30 | } 31 | } 32 | }, 33 | "formatter": { 34 | "enabled": true, 35 | "formatWithErrors": true, 36 | "lineWidth": 80, 37 | "indentWidth": 4, 38 | "indentStyle": "space" 39 | }, 40 | "javascript": { 41 | "formatter": { 42 | "semicolons": "asNeeded", 43 | "trailingCommas": "none" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zerodevapp/orchestra/c150d0d21f1b1fe38d9b62d4b7ca9849c1a885cf/bun.lockb -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zerodev/orchestra", 3 | "version": "0.2.0", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "build": "tsc", 9 | "changeset": "changeset", 10 | "changeset:release": "bun run build && changeset publish", 11 | "changeset:version": "changeset version && bun install --lockfile-only", 12 | "dev": "ts-node src/index.ts", 13 | "format": "biome format ./src --write", 14 | "lint": "biome check ./src", 15 | "lint:fix": "bun run lint --write", 16 | "start": "node dist/index.js" 17 | }, 18 | "bin": { 19 | "zerodev": "./dist/index.js" 20 | }, 21 | "keywords": [ 22 | "contract", 23 | "account-abstraction", 24 | "cli" 25 | ], 26 | "author": { 27 | "name": "ZeroDev", 28 | "url": "https://docs.zerodev.app/" 29 | }, 30 | "contributors": [ 31 | { 32 | "name": "leekt", 33 | "email": "leekt216@gmail.com" 34 | }, 35 | { 36 | "name": "derekchiang", 37 | "email": "me@derekchiang.com" 38 | }, 39 | { 40 | "name": "adnpark", 41 | "email": "aidenp.dev@gmail.com" 42 | } 43 | ], 44 | "license": "MIT", 45 | "dependencies": { 46 | "@zerodev/ecdsa-validator": "^5.4.9", 47 | "@zerodev/sdk": "^5.4.36", 48 | "chalk": "4.1.2", 49 | "cli-table3": "^0.6.5", 50 | "commander": "^11.1.0", 51 | "dotenv": "^16.5.0", 52 | "figlet": "^1.8.1", 53 | "ora": "^8.2.0", 54 | "tslib": "^2.8.1", 55 | "viem": "^2.30.6" 56 | }, 57 | "devDependencies": { 58 | "@biomejs/biome": "^1.9.4", 59 | "@changesets/changelog-git": "^0.1.14", 60 | "@changesets/changelog-github": "^0.4.8", 61 | "@changesets/cli": "^2.29.4", 62 | "@size-limit/esbuild-why": "^9.0.0", 63 | "@size-limit/preset-small-lib": "^9.0.0", 64 | "@types/figlet": "^1.7.0", 65 | "@types/node": "^18.19.110", 66 | "@types/ora": "^3.2.0", 67 | "simple-git-hooks": "^2.13.0", 68 | "ts-node": "^10.9.2", 69 | "typescript": "^5.8.3" 70 | }, 71 | "simple-git-hooks": { 72 | "pre-commit": "bun run format && bun run lint" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/action/computeAddress.ts: -------------------------------------------------------------------------------- 1 | import { type Address, type Hex, getContractAddress } from "viem" 2 | 3 | export const computeContractAddress = ( 4 | from: Hex, 5 | bytecode: Hex, 6 | salt: Hex 7 | ): Address => { 8 | return getContractAddress({ 9 | bytecode, 10 | from, 11 | opcode: "CREATE2", 12 | salt 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /src/action/deployContracts.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk" 2 | import ora from "ora" 3 | import type { Address, Hex } from "viem" 4 | import { http, createPublicClient, getAddress } from "viem" 5 | import { createKernelClient, getZeroDevBundlerRPC } from "../clients/index.js" 6 | import { DEPLOYER_CONTRACT_ADDRESS, type ZerodevChain } from "../constant.js" 7 | import { ensureHex, writeErrorLogToFile } from "../utils/index.js" 8 | import { computeContractAddress } from "./computeAddress.js" 9 | import { DeploymentStatus, checkDeploymentOnChain } from "./findDeployment.js" 10 | 11 | class AlreadyDeployedError extends Error { 12 | address: Address 13 | constructor(address: Address) { 14 | super(`Contract already deployed on Address ${address}`) 15 | this.name = "AlreadyDeployedError" 16 | this.address = address 17 | } 18 | } 19 | 20 | type DeployResult = [string, string] 21 | 22 | export const deployToChain = async ( 23 | privateKey: Hex, 24 | chain: ZerodevChain, 25 | bytecode: Hex, 26 | salt: Hex, 27 | expectedAddress: string | undefined, 28 | callGasLimit: bigint | undefined 29 | ): Promise => { 30 | const publicClient = createPublicClient({ 31 | chain: chain, 32 | transport: http() 33 | }) 34 | const kernelAccountClient = await createKernelClient(privateKey, chain) 35 | 36 | if (!kernelAccountClient.account) { 37 | throw new Error("Kernel account is not initialized") 38 | } 39 | 40 | const result = await publicClient 41 | .call({ 42 | account: kernelAccountClient.account.address, 43 | data: ensureHex(salt + bytecode.slice(2)), 44 | to: DEPLOYER_CONTRACT_ADDRESS 45 | }) 46 | .catch(async (error: Error) => { 47 | const address = computeContractAddress( 48 | DEPLOYER_CONTRACT_ADDRESS, 49 | bytecode, 50 | salt 51 | ) 52 | if ( 53 | (await checkDeploymentOnChain(publicClient, address)) === 54 | DeploymentStatus.Deployed 55 | ) { 56 | throw new AlreadyDeployedError(address) 57 | } 58 | if ( 59 | expectedAddress && 60 | expectedAddress.toLowerCase() !== address.toLowerCase() 61 | ) { 62 | throw new Error( 63 | `Contract will be deployed at ${address.toLowerCase()} does not match expected address ${expectedAddress.toLowerCase()}` 64 | ) 65 | } 66 | throw new Error( 67 | `Error calling contract ${DEPLOYER_CONTRACT_ADDRESS} : ${error}` 68 | ) 69 | }) 70 | 71 | if ( 72 | expectedAddress && 73 | result.data?.toLowerCase() !== expectedAddress.toLowerCase() 74 | ) { 75 | throw new Error( 76 | `Contract will be deployed at ${result.data?.toLowerCase()} does not match expected address ${expectedAddress.toLowerCase()}` 77 | ) 78 | } 79 | const opHash = await kernelAccountClient.sendUserOperation({ 80 | callData: await kernelAccountClient.account.encodeCalls([ 81 | { 82 | to: DEPLOYER_CONTRACT_ADDRESS, 83 | value: 0n, 84 | data: ensureHex(salt + bytecode.slice(2)) 85 | } 86 | ]) 87 | }) 88 | 89 | await kernelAccountClient.waitForUserOperationReceipt({ 90 | hash: opHash 91 | }) 92 | await kernelAccountClient.getUserOperationReceipt({ 93 | hash: opHash 94 | }) 95 | 96 | return [getAddress(result.data as Address), opHash] 97 | } 98 | 99 | export const deployContracts = async ( 100 | privateKey: Hex, 101 | bytecode: Hex, 102 | chains: ZerodevChain[], 103 | salt: Hex, 104 | expectedAddress: string | undefined, 105 | callGasLimit: bigint | undefined 106 | ) => { 107 | const spinner = ora( 108 | `Deploying contract on ${chains.map((chain) => chain.name).join(", ")}` 109 | ).start() 110 | let anyError = false 111 | const deployments = chains.map(async (chain) => { 112 | return deployToChain( 113 | privateKey, 114 | chain, 115 | bytecode, 116 | salt, 117 | expectedAddress, 118 | callGasLimit 119 | ) 120 | .then((result) => { 121 | spinner.succeed( 122 | `Contract deployed at "${result[0]}" on ${chalk.blueBright( 123 | chain.name 124 | )} with opHash "${ 125 | result[1] 126 | }" \n 🔗 Jiffyscan link for the transaction: https://jiffyscan.xyz/userOpHash/${ 127 | result[1] 128 | }` 129 | ) 130 | return result 131 | }) 132 | .catch((error) => { 133 | if (error instanceof AlreadyDeployedError) { 134 | spinner.warn( 135 | `Contract already deployed at ${ 136 | error.address 137 | } on ${chalk.yellowBright(chain.name)}` 138 | ) 139 | } else { 140 | writeErrorLogToFile(chain.name, error) 141 | anyError = true 142 | spinner.fail( 143 | `Deployment for ${chalk.redBright( 144 | chain.name 145 | )} failed! Check the error log at "./log" directory` 146 | ) 147 | } 148 | }) 149 | }) 150 | 151 | await Promise.allSettled(deployments) 152 | spinner.stop() 153 | if (anyError) { 154 | console.log("❌ Some deployments failed!") 155 | process.exit(1) 156 | } else { 157 | console.log("✅ All deployments process successfully finished!") 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/action/findDeployment.ts: -------------------------------------------------------------------------------- 1 | import type { Address, Hex, PublicClient } from "viem" 2 | import { http, createPublicClient } from "viem" 3 | import { getZeroDevBundlerRPC } from "../clients/index.js" 4 | import { DEPLOYER_CONTRACT_ADDRESS, type ZerodevChain } from "../constant.js" 5 | import { computeContractAddress } from "./computeAddress.js" 6 | export enum DeploymentStatus { 7 | Deployed = 0, 8 | NotDeployed = 1, 9 | Error = 2 10 | } 11 | 12 | export const checkDeploymentOnChain = async ( 13 | publicClient: PublicClient, 14 | contractAddress: Hex 15 | ): Promise => { 16 | const deployedBytecode = await publicClient.getBytecode({ 17 | address: contractAddress 18 | }) 19 | 20 | const nonce = await publicClient.getTransactionCount({ 21 | address: contractAddress 22 | }) 23 | 24 | return nonce > 0 || deployedBytecode 25 | ? DeploymentStatus.Deployed 26 | : DeploymentStatus.NotDeployed 27 | } 28 | 29 | export const findDeployment = async ( 30 | bytecode: Hex, 31 | salt: Hex, 32 | chains: ZerodevChain[] 33 | ): Promise<{ 34 | address: Address 35 | deployedChains: ZerodevChain[] 36 | notDeployedChains: ZerodevChain[] 37 | errorChains?: ZerodevChain[] 38 | }> => { 39 | const address = computeContractAddress( 40 | DEPLOYER_CONTRACT_ADDRESS, 41 | bytecode, 42 | salt 43 | ) 44 | 45 | const deploymentResults = await Promise.all( 46 | chains.map((chain) => { 47 | return checkDeploymentOnChain( 48 | createPublicClient({ 49 | chain: chain, 50 | transport: http() 51 | }), 52 | address 53 | ).catch(() => DeploymentStatus.Error) 54 | }) 55 | ) 56 | 57 | const deployedChains = chains.filter( 58 | (_, index) => deploymentResults[index] === DeploymentStatus.Deployed 59 | ) 60 | const notDeployedChains = chains.filter( 61 | (_, index) => deploymentResults[index] === DeploymentStatus.NotDeployed 62 | ) 63 | const errorChains = chains.filter( 64 | (_, index) => deploymentResults[index] === DeploymentStatus.Error 65 | ) 66 | 67 | return { address, deployedChains, notDeployedChains, errorChains } 68 | } 69 | -------------------------------------------------------------------------------- /src/action/getDeployerAddress.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Address, 3 | type Hex, 4 | concat, 5 | encodeFunctionData, 6 | getContractAddress, 7 | keccak256, 8 | pad, 9 | slice, 10 | toHex 11 | } from "viem" 12 | import { privateKeyToAccount } from "viem/accounts" 13 | import { ensureHex } from "../utils/index.js" 14 | export const getDeployerAddress = (privateKey: Hex, index: bigint): Address => { 15 | const signer = privateKeyToAccount(privateKey) 16 | const KernelAccountAbi = [ 17 | { 18 | inputs: [ 19 | { 20 | internalType: "contract IKernelValidator", 21 | name: "_defaultValidator", 22 | type: "address" 23 | }, 24 | { 25 | internalType: "bytes", 26 | name: "_data", 27 | type: "bytes" 28 | } 29 | ], 30 | name: "initialize", 31 | outputs: [], 32 | stateMutability: "payable", 33 | type: "function" 34 | } 35 | ] 36 | 37 | const data = encodeFunctionData({ 38 | abi: KernelAccountAbi, 39 | functionName: "initialize", 40 | args: ["0xd9AB5096a832b9ce79914329DAEE236f8Eea0390", signer.address] 41 | }) 42 | const salt = slice( 43 | keccak256(concat([data, pad(index ? toHex(index) : "0x00")])), 44 | 20 45 | ) 46 | const kernelAddress = getContractAddress({ 47 | from: "0x5de4839a76cf55d0c90e2061ef4386d962E15ae3" as Address, 48 | bytecodeHash: ensureHex( 49 | "0xee9d8350bd899dd261db689aafd87eb8a30f085adbaff48152399438ff4eed73" 50 | ), 51 | opcode: "CREATE2", 52 | salt: salt 53 | }) 54 | 55 | return kernelAddress 56 | } 57 | -------------------------------------------------------------------------------- /src/action/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./getDeployerAddress.js" 2 | export * from "./deployContracts.js" 3 | export * from "./computeAddress.js" 4 | export * from "./findDeployment.js" 5 | export * from "./verifyContracts.js" 6 | -------------------------------------------------------------------------------- /src/action/verifyContracts.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "node:child_process" 2 | import util from "node:util" 3 | import chalk from "chalk" 4 | import ora from "ora" 5 | import type { Address } from "viem" 6 | import type { ZerodevChain } from "../constant.js" 7 | 8 | const execPromise = util.promisify(exec) 9 | 10 | async function checkForgeAvailability() { 11 | try { 12 | await execPromise("forge --version") 13 | } catch { 14 | throw new Error( 15 | "forge command is not available, please install forge to verify contracts. https://book.getfoundry.sh/getting-started/installation" 16 | ) 17 | } 18 | } 19 | 20 | async function verifyContract( 21 | contractName: string, 22 | contractAddress: Address, 23 | chain: ZerodevChain 24 | ): Promise { 25 | if (!chain.explorerAPI) { 26 | throw new Error( 27 | `Explorer API key is not provided for ${chalk.yellowBright( 28 | chain.name 29 | )}` 30 | ) 31 | } 32 | const command = `forge verify-contract --chain ${chain.id} --verifier etherscan ${contractAddress} ${contractName} -e ${chain.explorerAPI} -a v2` 33 | 34 | try { 35 | const { stdout, stderr } = await execPromise(command) 36 | if (stderr) { 37 | return `Error verifying contract ${contractName} at ${contractAddress} on ${chalk.yellowBright( 38 | chain.name 39 | )}: ${stderr}` 40 | } 41 | if (stdout.includes("is already verified")) { 42 | return `Contract ${contractName} at ${contractAddress} on ${chalk.yellowBright( 43 | chain.name 44 | )} is already verified. Skipping verification.` 45 | } 46 | return `Successfully verified contract ${contractName} at ${contractAddress} on ${chalk.yellowBright( 47 | chain.name 48 | )}.` 49 | } catch (error) { 50 | throw new Error(`Error executing ${command}: ${error}`) 51 | } 52 | } 53 | 54 | export const verifyContracts = async ( 55 | contractName: string, 56 | contractAddress: Address, 57 | chains: ZerodevChain[] 58 | ) => { 59 | await checkForgeAvailability() 60 | const spinner = ora().start("Verifying contracts...") 61 | let anyError = false 62 | const verificationPromises = chains.map((chain) => 63 | verifyContract(contractName, contractAddress, chain) 64 | .then((message) => { 65 | if (message.includes("is already verified")) { 66 | ora().warn(message).start().stop() 67 | } else { 68 | ora().succeed(message).start().stop() 69 | } 70 | }) 71 | .catch((error) => { 72 | anyError = true 73 | return ora() 74 | .fail( 75 | `Verification failed on ${chain.name}: ${error.message}` 76 | ) 77 | .start() 78 | .stop() 79 | }) 80 | ) 81 | // Wait for all verifications to complete 82 | await Promise.all(verificationPromises) 83 | spinner.stop() 84 | if (anyError) { 85 | console.log("❌ Some verifications failed!") 86 | process.exit(1) 87 | } else { 88 | console.log("✅ All verifications process successfully finished!") 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/clients/createKernelClient.ts: -------------------------------------------------------------------------------- 1 | import { signerToEcdsaValidator } from "@zerodev/ecdsa-validator" 2 | import { 3 | type KernelAccountClient, 4 | createKernelAccount, 5 | createKernelAccountClient, 6 | createZeroDevPaymasterClient 7 | } from "@zerodev/sdk" 8 | import { getUserOperationGasPrice } from "@zerodev/sdk" 9 | import { KERNEL_V3_1, getEntryPoint } from "@zerodev/sdk/constants" 10 | import type { Hex } from "viem" 11 | 12 | import { http, createPublicClient } from "viem" 13 | import { privateKeyToAccount } from "viem/accounts" 14 | import type { ZerodevChain } from "../constant.js" 15 | import { getZeroDevBundlerRPC, getZeroDevPaymasterRPC } from "./index.js" 16 | 17 | export const createKernelClient = async ( 18 | privateKey: Hex, 19 | chain: ZerodevChain 20 | ): Promise => { 21 | const rpcUrl = getZeroDevBundlerRPC(chain.id, "PIMLICO") 22 | const paymasterRpcUrl = getZeroDevPaymasterRPC(chain.id, "PIMLICO") 23 | const entryPoint = getEntryPoint("0.7") 24 | 25 | const publicClient = createPublicClient({ 26 | chain: chain, 27 | transport: http(chain.rpcUrls.default.http[0]) 28 | }) 29 | const signer = privateKeyToAccount(privateKey) 30 | 31 | const ecdsaValidator = await signerToEcdsaValidator(publicClient, { 32 | signer, 33 | entryPoint, 34 | kernelVersion: KERNEL_V3_1 35 | }) 36 | 37 | // Construct a Kernel account 38 | const account = await createKernelAccount(publicClient, { 39 | plugins: { 40 | sudo: ecdsaValidator 41 | }, 42 | entryPoint, 43 | kernelVersion: KERNEL_V3_1 44 | }) 45 | 46 | const zerodevPaymaster = createZeroDevPaymasterClient({ 47 | chain: chain, 48 | transport: http(paymasterRpcUrl) 49 | }) 50 | // Construct a Kernel account client 51 | const kernelClient = createKernelAccountClient({ 52 | account: account, 53 | client: publicClient, 54 | chain: chain, 55 | bundlerTransport: http(rpcUrl), 56 | paymaster: { 57 | getPaymasterData(userOperation) { 58 | return zerodevPaymaster.sponsorUserOperation({ userOperation }) 59 | } 60 | } 61 | }) 62 | 63 | return kernelClient 64 | } 65 | -------------------------------------------------------------------------------- /src/clients/index.ts: -------------------------------------------------------------------------------- 1 | export const getZeroDevBundlerRPC = ( 2 | chainId: number, 3 | provider?: string 4 | ): string => { 5 | let rpc = `https://rpc.zerodev.app/api/v3/${process.env.ZERODEV_PROJECT_ID}/chain/${chainId}` 6 | if (provider) { 7 | rpc += `?provider=${provider}` 8 | } 9 | return rpc 10 | } 11 | export const getZeroDevPaymasterRPC = ( 12 | chainId: number, 13 | provider?: string 14 | ): string => { 15 | let rpc = `https://rpc.zerodev.app/api/v3/${process.env.ZERODEV_PROJECT_ID}/chain/${chainId}` 16 | if (provider) { 17 | rpc += `?provider=${provider}` 18 | } 19 | return rpc 20 | } 21 | export * from "./createKernelClient.js" 22 | -------------------------------------------------------------------------------- /src/command/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import crypto from "node:crypto" 3 | import chalk from "chalk" 4 | import Table from "cli-table3" 5 | import { Command } from "commander" 6 | import figlet from "figlet" 7 | import { 8 | computeContractAddress, 9 | deployContracts, 10 | findDeployment, 11 | getDeployerAddress, 12 | verifyContracts 13 | } from "../action/index.js" 14 | import { PRIVATE_KEY } from "../config.js" 15 | import { DEPLOYER_CONTRACT_ADDRESS, getSupportedChains } from "../constant.js" 16 | import { 17 | clearFiles, 18 | ensureHex, 19 | normalizeSalt, 20 | processAndValidateChains, 21 | readBytecodeFromFile, 22 | validateInputs, 23 | validatePrivateKey 24 | } from "../utils/index.js" 25 | 26 | export const program = new Command() 27 | 28 | const fileOption = [ 29 | "-f, --file ", 30 | "file path of bytecode to deploy, a.k.a. init code, or a JSON file containing the bytecode of the contract (such as the output file by Forge), in which case it's assumed that the constructor takes no arguments." 31 | ] as [string, string] 32 | 33 | program 34 | .name("zerodev") 35 | .description( 36 | "tool for deploying contracts to multichain with account abstraction" 37 | ) 38 | .usage(" [options]") 39 | .version("0.1.3") 40 | 41 | program.helpInformation = function () { 42 | const asciiArt = chalk.blueBright( 43 | figlet.textSync("ZeroDev Orchestra", { 44 | horizontalLayout: "default", 45 | verticalLayout: "default", 46 | width: 100, 47 | whitespaceBreak: true 48 | }) 49 | ) 50 | 51 | const originalHelpInformation = Command.prototype.helpInformation.call(this) 52 | return `\n\n${asciiArt}\n\n\n${originalHelpInformation}` 53 | } 54 | 55 | program 56 | .command("chains") 57 | .description("Show the list of available chains") 58 | .action(async () => { 59 | const chains = (await getSupportedChains()).map((chain) => [ 60 | chain.name, 61 | chain.testnet ? chalk.green("testnet") : chalk.blue("mainnet") 62 | ]) 63 | 64 | const table = new Table({ 65 | head: ["Name", "Type"], 66 | chars: { mid: "", "left-mid": "", "mid-mid": "", "right-mid": "" } 67 | }) 68 | 69 | for (const chain of chains) { 70 | table.push(chain) 71 | } 72 | 73 | console.log("[Available chains]") 74 | console.log(table.toString()) 75 | }) 76 | 77 | program 78 | .command("compute-address") 79 | .description("Compute the address to be deployed") 80 | .option(...fileOption) 81 | .option("-b, --bytecode ", "bytecode to deploy") 82 | .option( 83 | "-s, --salt ", 84 | "salt to be used for CREATE2. This can be a full 32-byte hex string or a shorter numeric representation that will be converted to a 32-byte hex string." 85 | ) 86 | .action(async (options) => { 87 | const { file, bytecode, salt } = options 88 | 89 | const normalizedSalt = normalizeSalt(salt) 90 | validateInputs(file, bytecode, normalizedSalt, undefined) 91 | 92 | let bytecodeToDeploy = bytecode 93 | if (file) { 94 | bytecodeToDeploy = readBytecodeFromFile(file) 95 | } 96 | 97 | const address = computeContractAddress( 98 | DEPLOYER_CONTRACT_ADDRESS, 99 | ensureHex(bytecodeToDeploy), 100 | ensureHex(normalizedSalt) 101 | ) 102 | console.log(`computed address: ${address}`) 103 | }) 104 | 105 | program 106 | .command("get-deployer-address") 107 | .description("Get the deployer's address") 108 | .action(async () => { 109 | const address = getDeployerAddress(validatePrivateKey(PRIVATE_KEY), 0n) 110 | console.log(`deployer address: ${address}`) 111 | }) 112 | 113 | program 114 | .command("deploy") 115 | .description( 116 | "Deploy contracts deterministically using CREATE2, in order of the chains specified" 117 | ) 118 | .option(...fileOption) 119 | .option("-b, --bytecode ", "bytecode to deploy") 120 | .option( 121 | "-s, --salt ", 122 | "salt to be used for CREATE2. This can be a full 32-byte hex string or a shorter numeric representation that will be converted to a 32-byte hex string." 123 | ) 124 | .option("-t, --testnet-all", "select all testnets", false) 125 | .option("-m, --mainnet-all", "select all mainnets", false) 126 | .option("-a, --all-networks", "select all networks", false) 127 | .option("-c, --chains [CHAINS]", "list of chains for deploying contracts") 128 | .option("-e, --expected-address [ADDRESS]", "expected address to confirm") 129 | .option( 130 | "-v, --verify-contract [CONTRACT_NAME]", 131 | "verify the deployment on Etherscan" 132 | ) 133 | .option("-g, --call-gas-limit ", "gas limit for the call") 134 | .action(async (options) => { 135 | const { 136 | file, 137 | bytecode, 138 | salt, 139 | testnetAll, 140 | mainnetAll, 141 | allNetworks, 142 | chains, 143 | expectedAddress, 144 | verifyContract, 145 | callGasLimit 146 | } = options 147 | 148 | const normalizedSalt = normalizeSalt(salt) 149 | 150 | validateInputs(file, bytecode, normalizedSalt, expectedAddress) 151 | const chainObjects = await processAndValidateChains({ 152 | testnetAll, 153 | mainnetAll, 154 | allNetworks, 155 | chainOption: chains 156 | }) 157 | 158 | let bytecodeToDeploy = bytecode 159 | if (file) { 160 | bytecodeToDeploy = readBytecodeFromFile(file) 161 | } 162 | 163 | await deployContracts( 164 | validatePrivateKey(PRIVATE_KEY), 165 | ensureHex(bytecodeToDeploy), 166 | chainObjects, 167 | ensureHex(normalizedSalt), 168 | expectedAddress, 169 | callGasLimit ? BigInt(callGasLimit) : undefined 170 | ) 171 | 172 | console.log("✅ Contracts deployed successfully!") 173 | 174 | if (verifyContract) { 175 | console.log("Verifying contracts on Etherscan...") 176 | await verifyContracts( 177 | verifyContract, 178 | computeContractAddress( 179 | DEPLOYER_CONTRACT_ADDRESS, 180 | ensureHex(bytecodeToDeploy), 181 | ensureHex(normalizedSalt) 182 | ), 183 | chainObjects 184 | ) 185 | } 186 | 187 | console.log("✅ Contracts verified successfully!") 188 | }) 189 | 190 | program 191 | .command("check-deployment") 192 | .description( 193 | "check whether the contract has already been deployed on the specified networks" 194 | ) 195 | .option(...fileOption) 196 | .option("-b, --bytecode ", "deployed bytecode") 197 | .option( 198 | "-s, --salt ", 199 | "salt to be used for CREATE2. This can be a full 32-byte hex string or a shorter numeric representation that will be converted to a 32-byte hex string." 200 | ) 201 | .option("-t, --testnet-all", "select all testnets", false) 202 | .option("-m, --mainnet-all", "select all mainnets", false) 203 | .option("-a, --all-networks", "select all networks", false) 204 | .option("-c, --chains [CHAINS]", "list of chains for checking") 205 | .action(async (options) => { 206 | const { 207 | file, 208 | bytecode, 209 | salt, 210 | testnetAll, 211 | mainnetAll, 212 | allNetworks, 213 | chains 214 | } = options 215 | 216 | const normalizedSalt = normalizeSalt(salt) 217 | validateInputs(file, bytecode, normalizedSalt, undefined) 218 | const chainObjects = await processAndValidateChains({ 219 | testnetAll, 220 | mainnetAll, 221 | allNetworks, 222 | chainOption: chains 223 | }) 224 | 225 | let bytecodeToDeploy = bytecode 226 | if (file) { 227 | bytecodeToDeploy = readBytecodeFromFile(file) 228 | } 229 | 230 | const { address, deployedChains, notDeployedChains } = 231 | await findDeployment( 232 | ensureHex(bytecodeToDeploy), 233 | ensureHex(normalizedSalt), 234 | chainObjects 235 | ) 236 | 237 | console.log(`contract address: ${address}`) 238 | console.log("deployed on:") 239 | for (const chain of deployedChains) { 240 | console.log(`- ${chain.name}`) 241 | } 242 | console.log("not deployed on:") 243 | for (const chain of notDeployedChains) { 244 | console.log(`- ${chain.name}`) 245 | } 246 | }) 247 | 248 | program 249 | .command("clear-log") 250 | .description("clear the log files") 251 | .action(() => { 252 | clearFiles("./log") 253 | console.log("✅ Log files are cleared!") 254 | }) 255 | 256 | program 257 | .command("generate-salt") 258 | .description( 259 | "generate a random 32 bytes salt, or convert the numeric input to salt" 260 | ) 261 | .option("-i, --input ", "input to convert to salt") 262 | .action((options) => { 263 | let salt: string 264 | if (options.input) { 265 | const inputNum = BigInt(options.input) 266 | salt = inputNum.toString(16).padStart(64, "0") // pad the input with zeros to make it 32 bytes 267 | } else { 268 | salt = crypto.randomBytes(32).toString("hex") 269 | } 270 | console.log(`Generated salt: ${ensureHex(salt)}`) 271 | }) 272 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv" 2 | import type { Hex } from "viem" 3 | import { ensureHex } from "./utils/index.js" 4 | 5 | dotenv.config() 6 | 7 | function getEnvVar(name: string): string | null { 8 | const value = process.env[name] 9 | if (!value) { 10 | return null 11 | } 12 | return value 13 | } 14 | 15 | const privateKeyEnv = getEnvVar("PRIVATE_KEY") 16 | export const PRIVATE_KEY: Hex | null = privateKeyEnv 17 | ? ensureHex(privateKeyEnv) 18 | : null 19 | -------------------------------------------------------------------------------- /src/constant.ts: -------------------------------------------------------------------------------- 1 | import type { Chain } from "viem/chains" 2 | 3 | /** @dev deterministic-deployment-proxy contract address */ 4 | export const DEPLOYER_CONTRACT_ADDRESS = 5 | "0x4e59b44847b379578588920ca78fbf26c0b4956c" 6 | 7 | export type ZerodevChain = { 8 | onlySelfFunded: boolean 9 | rollupProvider: string | null 10 | deprecated: boolean 11 | explorerAPI: string | null 12 | } & Chain 13 | 14 | interface ZerodevChainResponse { 15 | chainId: number 16 | name: string 17 | nativeCurrencyName: string 18 | nativeCurrencySymbol: string 19 | nativeCurrencyDecimals: number 20 | rpcUrl: string 21 | explorerUrl: string 22 | testnet: boolean 23 | onlySelfFunded: boolean 24 | rollupProvider: string | null 25 | deprecated: boolean 26 | } 27 | 28 | interface ZerodevProjectResponse { 29 | id: string 30 | name: string 31 | teamId: string 32 | chains: { 33 | chain_id: number 34 | name: string 35 | testnet: boolean 36 | }[] 37 | } 38 | 39 | /* 40 | curl --request GET \ 41 | --url https://prod-api-us-east.onrender.com/v2/chains \ 42 | --header `X-API-KEY: ${process.env.ZERODEV_API_KEY}` \ 43 | --header 'accept: application/json' 44 | { 45 | "chainId": 1, 46 | "name": "Ethereum", 47 | "nativeCurrencyName": "Ether", 48 | "nativeCurrencySymbol": "ETH", 49 | "nativeCurrencyDecimals": 18, 50 | "rpcUrl": "https://eth.llamarpc.com", 51 | "explorerUrl": "https://etherscan.io", 52 | "testnet": false, 53 | "onlySelfFunded": false, 54 | "rollupProvider": null, 55 | "deprecated": false 56 | }, 57 | */ 58 | 59 | export const getSupportedChains = async (): Promise => { 60 | const response = await fetch( 61 | "https://prod-api-us-east.onrender.com/v2/chains", 62 | { 63 | headers: { 64 | "X-API-KEY": process.env.ZERODEV_API_KEY ?? "", 65 | accept: "application/json" 66 | } 67 | } 68 | ) 69 | 70 | if (!response.ok) { 71 | throw new Error(`Failed to fetch chains: ${response.statusText}`) 72 | } 73 | 74 | const data = (await response.json()) as ZerodevChainResponse[] 75 | 76 | const chains_all = data.reduce( 77 | (acc, chain) => { 78 | const key = `${chain.name}-${chain.chainId}` 79 | acc[key] = { 80 | id: chain.chainId, 81 | name: chain.name, 82 | nativeCurrency: { 83 | name: chain.nativeCurrencyName, 84 | symbol: chain.nativeCurrencySymbol, 85 | decimals: chain.nativeCurrencyDecimals 86 | }, 87 | rpcUrls: { 88 | default: { http: [chain.rpcUrl] } 89 | }, 90 | onlySelfFunded: chain.onlySelfFunded, 91 | rollupProvider: chain.rollupProvider, 92 | deprecated: chain.deprecated, 93 | testnet: chain.testnet, 94 | explorerAPI: 95 | process.env[ 96 | `${chain.name.toUpperCase()}_VERIFICATION_API_KEY` 97 | ] ?? 98 | process.env.ETHERSCAN_API_KEY ?? 99 | "" // try get chain specific api key, if not found, use etherscan api key 100 | } 101 | return acc 102 | }, 103 | {} as Record 104 | ) 105 | 106 | const response_project = await fetch( 107 | `https://prod-api-us-east.onrender.com/v2/projects/${process.env.ZERODEV_PROJECT_ID}`, 108 | { 109 | headers: { 110 | "X-API-KEY": process.env.ZERODEV_API_KEY ?? "", 111 | accept: "application/json" 112 | } 113 | } 114 | ) 115 | 116 | if (!response_project.ok) { 117 | throw new Error( 118 | `Failed to fetch project chains: ${response_project.statusText}` 119 | ) 120 | } 121 | 122 | const chains_project = 123 | (await response_project.json()) as ZerodevProjectResponse 124 | 125 | const supportedChains = chains_project.chains 126 | .map((chain) => { 127 | const key = `${chain.name}-${chain.chain_id}` 128 | const matchedChain = chains_all[key] 129 | if (!matchedChain) { 130 | return null 131 | } 132 | return matchedChain 133 | }) 134 | .filter((chain): chain is ZerodevChain => chain !== null) 135 | 136 | if (supportedChains.length === 0) { 137 | throw new Error("No supported chains found for the project") 138 | } 139 | 140 | return supportedChains 141 | } 142 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import dotenv from "dotenv" 3 | import { program } from "./command/index.js" 4 | 5 | dotenv.config() 6 | 7 | async function main() { 8 | program.parse(process.argv) 9 | } 10 | 11 | main().catch((err) => { 12 | console.error(err) 13 | process.exit(1) 14 | }) 15 | -------------------------------------------------------------------------------- /src/utils/file.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs" 2 | import path from "node:path" 3 | import { encodeDeployData } from "viem" 4 | 5 | export const readBytecodeFromFile = (pathToBytecode: string): string => { 6 | const content = fs 7 | .readFileSync(path.resolve(process.cwd(), pathToBytecode), "utf8") 8 | .replace(/\n+$/, "") 9 | 10 | // Check if this is a JSON file. 11 | // If it is, we assume that it's a compilation artifact as outputted by Forge. 12 | if (pathToBytecode.endsWith(".json")) { 13 | try { 14 | const json = JSON.parse(content) 15 | return ( 16 | encodeDeployData({ 17 | abi: json.abi, 18 | bytecode: json.bytecode, 19 | args: [] 20 | // biome-ignore lint/suspicious/noExplicitAny: reason 21 | }) as any 22 | ).object 23 | } catch (error) { 24 | console.error( 25 | `Error: Failed to parse JSON file ${pathToBytecode}.\nPlease ensure that this is a compilation artifact as outputted by tools such as Forge.` 26 | ) 27 | console.error(error) 28 | process.exit(1) 29 | } 30 | } else { 31 | return content 32 | } 33 | } 34 | 35 | export const writeErrorLogToFile = (chainName: string, error: Error): void => { 36 | const logDir = path.resolve(process.cwd(), "log") 37 | 38 | if (!fs.existsSync(logDir)) { 39 | fs.mkdirSync(logDir, { recursive: true }) 40 | } 41 | 42 | const timestamp = new Date().toISOString().replace(/[:.]/g, "-") 43 | fs.writeFileSync( 44 | path.join(logDir, `${chainName}_error_${timestamp}.log`), 45 | error.toString() 46 | ) 47 | } 48 | 49 | export const clearFiles = (dir: string) => { 50 | const absoluteLogDir = path.resolve(process.cwd(), dir) 51 | 52 | for (const file of fs.readdirSync(absoluteLogDir)) { 53 | const filePath = path.join(absoluteLogDir, file) 54 | if (fs.statSync(filePath).isFile()) { 55 | try { 56 | fs.unlinkSync(filePath) 57 | } catch (error) { 58 | console.error(`Failed to delete file ${filePath}:`, error) 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/utils/format.ts: -------------------------------------------------------------------------------- 1 | import type { Hex } from "viem" 2 | 3 | const HEX_REGEX = /^[0-9a-fA-F]*$/ 4 | const SALT_REGEX = /^0x[0-9a-fA-F]{64}$/ 5 | 6 | export function ensureHex(str: string): Hex { 7 | if (!str.startsWith("0x") && HEX_REGEX.test(str) && str.length % 2 === 0) { 8 | return `0x${str}` as Hex 9 | } 10 | if ( 11 | str.startsWith("0x") && 12 | HEX_REGEX.test(str.slice(2)) && 13 | str.length % 2 === 0 14 | ) { 15 | return str as Hex 16 | } 17 | throw new Error("Invalid hex string") 18 | } 19 | 20 | // Convert salt to a 32 bytes hex string if it's not already 21 | export function normalizeSalt(salt: string | undefined): string { 22 | if (!salt) { 23 | console.error("Salt not specified, please provide a salt") 24 | process.exit(1) 25 | } 26 | 27 | if (SALT_REGEX.test(salt)) { 28 | return salt 29 | } 30 | const saltBigInt = BigInt(salt) // Convert to BigInt to handle hex conversion 31 | return `0x${saltBigInt.toString(16).padStart(64, "0")}` 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./format.js" 2 | export * from "./validate.js" 3 | export * from "./file.js" 4 | -------------------------------------------------------------------------------- /src/utils/validate.ts: -------------------------------------------------------------------------------- 1 | import type { Hex } from "viem" 2 | import { type ZerodevChain, getSupportedChains } from "../constant.js" 3 | import { readBytecodeFromFile } from "./file.js" 4 | 5 | const PRIVATE_KEY_REGEX = /^0x[0-9a-fA-F]{64}$/ 6 | const BYTECODE_REGEX = /^0x[0-9a-fA-F]*$/ 7 | const SALT_REGEX = /^0x[0-9a-fA-F]{64}$/ 8 | const ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/ 9 | 10 | export const validatePrivateKey = (privateKey: Hex | null): Hex => { 11 | if (!privateKey) { 12 | console.error("Error: This command requires a private key") 13 | process.exit(1) 14 | } 15 | if (!PRIVATE_KEY_REGEX.test(privateKey)) { 16 | console.error("Error: Private key must be a 32 bytes hex string") 17 | process.exit(1) 18 | } 19 | return privateKey 20 | } 21 | 22 | export const validateInputs = ( 23 | filePath: string | undefined, 24 | bytecode: string | undefined, 25 | salt: string, 26 | expectedAddress: string | undefined 27 | ) => { 28 | if (!filePath && !bytecode) { 29 | console.error("Error: Either filePath or bytecode must be specified") 30 | process.exit(1) 31 | } 32 | 33 | if (filePath && bytecode) { 34 | console.error( 35 | "Error: Only one of filePath and bytecode can be specified" 36 | ) 37 | process.exit(1) 38 | } 39 | 40 | const bytecodeToValidate = filePath 41 | ? readBytecodeFromFile(filePath) 42 | : bytecode 43 | 44 | if (!bytecodeToValidate) { 45 | console.error("Error: Bytecode must be specified") 46 | process.exit(1) 47 | } 48 | 49 | if ( 50 | !BYTECODE_REGEX.test(bytecodeToValidate) || 51 | bytecodeToValidate.length % 2 !== 0 52 | ) { 53 | console.error("Error: Bytecode must be a hexadecimal string") 54 | process.exit(1) 55 | } 56 | 57 | if (!SALT_REGEX.test(salt)) { 58 | console.error("Error: Salt must be a 32 bytes hex string") 59 | process.exit(1) 60 | } 61 | 62 | if (expectedAddress && !ADDRESS_REGEX.test(expectedAddress)) { 63 | console.error("Error: Expected address must be a 20 bytes hex string") 64 | process.exit(1) 65 | } 66 | } 67 | 68 | interface CommandOptions { 69 | testnetAll?: boolean 70 | mainnetAll?: boolean 71 | allNetworks?: boolean 72 | chainOption?: string 73 | } 74 | 75 | export const processAndValidateChains = async ( 76 | options: CommandOptions 77 | ): Promise => { 78 | const supportedChains = await getSupportedChains() 79 | 80 | // Check for mutually exclusive options 81 | const exclusiveOptions = [ 82 | options.chainOption !== undefined, 83 | options.testnetAll, 84 | options.mainnetAll, 85 | options.allNetworks 86 | ] 87 | const selectedOptionsCount = exclusiveOptions.filter( 88 | (isSelected) => isSelected 89 | ).length 90 | 91 | if (selectedOptionsCount === 0) { 92 | console.error( 93 | "Error: At least one of -c, -t, -m, -a options must be specified" 94 | ) 95 | process.exit(1) 96 | } else if (selectedOptionsCount > 1) { 97 | console.error( 98 | "Error: Options -c, -t, -m, -a are mutually exclusive and cannot be used together" 99 | ) 100 | process.exit(1) 101 | } 102 | 103 | let chains: ZerodevChain[] 104 | if (options.testnetAll) { 105 | chains = supportedChains.filter((chain) => chain.testnet) 106 | } else if (options.mainnetAll) { 107 | chains = supportedChains.filter((chain) => !chain.testnet) 108 | } else if (options.allNetworks) { 109 | chains = supportedChains 110 | } else if (options.chainOption) { 111 | const chainNames = options.chainOption 112 | ? options.chainOption.split(",") 113 | : [] 114 | 115 | chains = chainNames 116 | .map((chainName) => 117 | supportedChains.find( 118 | (chain) => 119 | chain.name.toLowerCase() === chainName.toLowerCase() 120 | ) 121 | ) 122 | .filter((chain) => chain !== undefined) 123 | } else { 124 | console.error( 125 | "Error: At least one of -c, -t, -m, -a options must be specified" 126 | ) 127 | process.exit(1) 128 | } 129 | return chains 130 | } 131 | -------------------------------------------------------------------------------- /test/deployContract.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { deployToChain,deployContracts } from "../src/action/deployContracts"; 3 | import { processAndValidateChains } from "../src/utils"; 4 | import {generatePrivateKey,} from "viem/accounts"; 5 | import { concat } from 'viem' 6 | import { getSupportedChains } from "../src/constant"; 7 | 8 | 9 | const PRIVATE_KEY = generatePrivateKey(); // this is copied from viem.sh docs, so don't use this in production and no it's not even ours 10 | 11 | test("deployContract", async () => { 12 | await deployToChain( 13 | PRIVATE_KEY, 14 | (await processAndValidateChains({ 15 | testnetAll : false, 16 | mainnetAll : false, 17 | allNetworks : false, 18 | chainOption : "Sepolia,Base Sepolia" 19 | }))[0], 20 | concat(['0x00', generatePrivateKey()]), 21 | generatePrivateKey(), 22 | undefined, 23 | undefined 24 | ); 25 | }, 30000); 26 | 27 | test("deployContractTestnet", async () => { 28 | await deployContracts( 29 | PRIVATE_KEY, 30 | concat(['0x00', generatePrivateKey()]), 31 | await processAndValidateChains({ 32 | testnetAll : true, 33 | mainnetAll : false, 34 | allNetworks : false, 35 | }), 36 | generatePrivateKey(), 37 | undefined, 38 | undefined 39 | ); 40 | }, 30000); 41 | 42 | test("getSupportedChains", async () => { 43 | const chains = await getSupportedChains(); 44 | console.log(chains.map((chain) => chain.name + " " + chain.id)); 45 | expect(chains).toBeDefined(); 46 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "./src", 4 | "outDir": "./dist", 5 | "lib": ["ES2022"], 6 | "module": "ES2022", 7 | "moduleResolution": "Bundler", 8 | "target": "ES2021", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "resolveJsonModule": true, 14 | "experimentalDecorators": true, 15 | "emitDecoratorMetadata": true, 16 | "strictPropertyInitialization": false, 17 | "declaration": true, 18 | "declarationMap": true 19 | }, 20 | "include": ["src/**/*"], 21 | "exclude": ["node_modules", "dist"] 22 | } 23 | --------------------------------------------------------------------------------