# deployoor Deploy EVM contracts from TypeScript with your own viem wallet. A deploy is an artifact plus a client, so scripts, tests, and your app share typed contract objects. Hardhat and Foundry. ## When to use deployoor deployoor deploys EVM smart contracts from TypeScript with a viem wallet client you supply, records every deploy as plain JSON under `deployments/-/`, and hands back a typed viem contract object. It is an MIT-licensed npm package: no account, no API key, no hosted service. Reach for it to: - deploy a contract from a TypeScript script, with no framework runtime booted - deploy the same contracts to several chains in one loop over viem clients - make a deploy step idempotent, reusing a record unless the bytecode, constructor args or linked libraries changed - sign with a wallet other than a raw private key: an encrypted keystore, cloud KMS, a Ledger, Privy, Turnkey, Coinbase CDP, Openfort, or a JSON-RPC account - give an app typed contract access with no copied addresses or ABIs, through `@deployoor/wagmi` - run integration tests over the real deploy path, in memory, through `@deployoor/testing` - verify already-deployed contracts from committed records, with no recompile Look elsewhere to compile or test Solidity (that stays with Hardhat or Foundry), to run a local chain for a person to use, to orchestrate proxy upgrades, or if the project has no TypeScript. How an agent should drive it: `pnpm add -D deployoor viem`, compile, then `npx deployoor generate` to write `./deployers/`, then call the generated `getOrDeploy({ walletClient, publicClient, args })` from an ordinary Node script. Full instructions, including the API surface and the mistakes that cost the most time, are at https://deployoor.dev/AGENTS.md. # Introduction **Deploy contracts from TypeScript. Bring your own wallet.** A deploy is an artifact plus a viem wallet, so deploy scripts are ordinary Node programs, tests call the same functions, and every chain is a client you construct. ## Why we built this deployoor started with a few questions we kept failing to answer while using other tools. * **If deploying or interacting with a contract is really just a wallet and some bytecode, why does a whole [Hardhat](https://hardhat.org) environment have to be running for it?**
Testing a contract, deploying it, and later reading a value off it are separate jobs at separate points in a project's life, and all three go through the same runtime. * **Why are we still passing private keys around in `.env` files?**
A raw key in a `.env` file is a shared company credential: no audit trail, no per-person scope, and no way to revoke one laptop without rotating for everybody. It is easy to wave that through because it is only staging, and that is where the second problem starts. Staging ends up as a bare EOA that someone has to keep topping up with gas, while production signs with a Safe or a key in AWS KMS. The two environments no longer sign the same way, so the path you rehearse is not the path you ship. Using one signing method across both removes that drift, and [Privy](https://privy.io), [Turnkey](https://www.turnkey.com), [Dfns](https://dfns.co) and [Openfort](https://www.openfort.io) make it practical: the key stays on their side, access is granted per person or per service, spending policies are enforced for you, and sponsored gas or account abstraction takes the funding chore away. * **Why does multichain still feel like an edge case?**
Shipping the same contracts to several chains is ordinary Ethereum development now, but the tooling still treats one chain as the default and every chain after it as a special arrangement. Same contract, same arguments, fifteen chains: it should be a loop over an array. * **And why is the deploy script we trust with real money not the one our tests actually run?**
Tests deploy through the framework's own helper, while production runs a script somebody wrote by hand. They drift apart, and the version carrying the money is the one with less practice. They all come from one thing: a framework that owns the deploy step ends up owning your wallet and your network list too. Signing becomes a config file the framework reads, so a signer it has no plugin for is a signer you cannot use. So deployoor leaves compiling and node-running to Hardhat and [Foundry](https://getfoundry.sh), and gives the wallet layer back to you. [viem](https://viem.sh) makes that handoff easy, because a wallet is already a plain object there: a local key, an encrypted keystore, cloud KMS, any of the services above, a [Ledger](https://www.ledger.com), or a JSON-RPC account where no key ever enters your process. What a deploy actually needs turns out to be small: **a viem wallet and some bytecode**. Deploying is then a function call, and the same deploy function runs in a unit test, in the script that stands up a protocol holding real money, and in the throwaway script you write three months later. It hands back an ordinary typed viem object, so [your tests](/guides/testing) and [your frontend](/guides/consumption) describe the contract with the same types and cannot drift apart without the compiler saying so. Every deploy is recorded per chain as plain JSON, so calling the function again returns the recorded contract instead of deploying a second one. [Hooks](/guides/plugins) cover the rest: verify on Etherscan, write an address book, post to your engineering channel when the Sepolia stack moves under someone's feet. deployoor is a library rather than a runtime. We think it changes how you work with contracts across their whole life, not only on the day you deploy them. ## Who this is for TypeScript and JavaScript teams. deployoor generates functions you call from `.ts`, and its output is consumed by [`viem`](https://viem.sh) and [`@wagmi/cli`](https://wagmi.sh/cli), so it earns its place when the code around your contracts is TypeScript. That is also why it reads Foundry artifacts. A Foundry-native project already has excellent Solidity tests and no good way to drive its contracts from TypeScript. Point deployoor at `out/` and you keep `forge build` and `forge test`, and additionally get typed deploy scripts and TS integration tests over the same artifacts, with no Hardhat required. Hardhat v2 and v3 work the same way. ## How it works ```mermaid flowchart LR A[Compile] --> B[Generate deployers] B --> C[Deploy] C --> D[Record] D --> E[Typed viem object everywhere] ``` 1. **Compile** with Hardhat (v2 or v3) or Foundry. deployoor reads your artifacts. 2. **Generate** typed `getOrDeploy` functions into `./deployers/`. 3. **Deploy** with a plain TypeScript script. Each run records to `deployments/-/`. 4. **Use** the result immediately: every `getOrDeploy` call returns a **typed viem contract object** (`contract.read.*`, `contract.write.*`) in deploy scripts, in your app via [`@wagmi/cli`](/guides/consumption), and in tests via [`@deployoor/testing`](/guides/testing). # Comparisons Three ways to deploy an EVM contract from a JavaScript project, and how each one differs from deployoor. * [Hardhat](/comparison/hardhat), where scripts run against the Hardhat runtime and nothing records what you deployed * [Hardhat Ignition](/comparison/hardhat-ignition), where you describe a deployment as a module and run it through the CLI * [hardhat-deploy](/comparison/hardhat-deploy), which adds records and idempotency but keeps them inside the framework Every page covers the same five jobs: setting up a project, deploying to one chain, deploying to many, running ops scripts, and handing deployments to the frontend team. The other tool goes first, deployoor second. ## The one idea A deploy needs three things. Compiled artifacts, a chain to talk to, and something that can sign. Frameworks bundle all three. Once a tool owns your compiler it ends up owning your wallet and your network list too, because they all arrive through the same runtime. deployoor reads artifacts from whatever compiled them, and takes the other two as arguments: ```ts const { contract } = await getOrDeployCounter({ walletClient, publicClient, args: [0n] }); ``` That `walletClient` carries the account and the chain. Nothing gets registered anywhere, so no config file sits between you and either one. That is the whole design. Multi-chain as a loop, tests that are just the deploy script, ops scripts sharing the frontend's bindings: all of it follows from that single split. Deployment should stop being a framework feature, the way data access did once an ORM became a library you call instead of something your web framework handed you. ## What the framework keeps Hardhat and Foundry stay good at the things they are genuinely good at. Compiling Solidity into artifacts. Running a local node with real dev controls, so you can move balances, warp time, take snapshots, and impersonate accounts. Forking a live chain. You bring the wallet. # Hardhat The same project built three times: a Counter deployed to a testnet, deployed again across several chains, incremented by an ops script, covered by a test, and handed to a frontend. Plain Hardhat scripts, no deploy plugin involved. Hardhat 2 and Hardhat 3 differ enough that each gets its own section, with deployoor alongside. Each job below lists the files it touches. That file list is the comparison. ## Minimum setup ### Hardhat v2 ```js require("@nomicfoundation/hardhat-viem"); module.exports = { solidity: "0.8.24", networks: { sepolia: { url: process.env.SEPOLIA_RPC_URL, accounts: [process.env.SEPOLIA_PRIVATE_KEY], }, }, }; ``` ```bash npm install --save-dev hardhat @nomicfoundation/hardhat-viem npx hardhat compile ``` * 🚫 A chain you have not listed is a chain you cannot reach * 🚫 The signer is whatever a private key can be, so a hardware or hosted wallet needs a plugin that understands it ### Hardhat v3 ```ts import { configVariable } from "hardhat/config"; import hardhatViemPlugin from "@nomicfoundation/hardhat-viem"; export default { plugins: [hardhatViemPlugin], solidity: "0.8.24", networks: { sepolia: { type: "http", chainType: "l1", url: configVariable("SEPOLIA_RPC_URL"), accounts: [configVariable("SEPOLIA_PRIVATE_KEY")], }, }, }; ``` ```bash npm install --save-dev hardhat @nomicfoundation/hardhat-viem npx hardhat compile ``` `configVariable` is a real improvement on v2: secrets come from an encrypted keystore rather than a plain environment read. * 🚫 The chain list is still config, so an RPC you have not declared is out of reach * 🚫 The keystore holds keys, so a signer that is not a key still needs a plugin ### deployoor No files. ```bash pnpm add -D deployoor npx hardhat compile # or `forge build` npx deployoor generate ``` `generate` reads your artifacts and writes one typed function per contract into `./deployers`. There is no network list and no accounts stanza, because a chain and a signer are arguments. * βœ… Nothing to configure before the first deploy * βœ… `deployoor.config.ts` exists for output paths and plugins, and stays optional ## Deploying to one chain ### Hardhat v2 Plus `hardhat.config.js`, from setup. ```ts import hre from "hardhat"; const counter = await hre.viem.deployContract("Counter", [0n]); console.log("Counter:", counter.address); ``` ```bash npx hardhat run scripts/deploy.ts --network sepolia ``` * 🚫 The address exists in the console output and nowhere else * 🚫 Nothing on disk records what was deployed, so it lands in an env var, a Notion page, or a Slack message * 🚫 The ABI is copied separately out of `artifacts/` * 🚫 Run the script twice and you get two contracts, because there is no notion of "already deployed" ### Hardhat v3 Plus `hardhat.config.ts`, from setup. ```ts import { network } from "hardhat"; const { viem } = await network.create(); const counter = await viem.deployContract("Counter", [0n]); console.log("Counter:", counter.address); ``` ```bash npx hardhat run scripts/deploy.ts --network sepolia ``` * 🚫 Same story: the address is console output, and nothing records it * 🚫 The ABI is still copied out of `artifacts/` * 🚫 Still no idempotency, so a second run is a second contract ### deployoor The one place a signer is built. Swapping custody is a change to this file and nothing else. ```ts import type { Hex } from "viem"; import { privateKeyToAccount } from "viem/accounts"; export const deployer = privateKeyToAccount(process.env.PRIVATE_KEY as Hex); ``` A chain in, the two clients out. Written once, imported by every script and test. ```ts import { createPublicClient, createWalletClient, http, type Chain } from "viem"; import { base, optimism, sepolia } from "viem/chains"; import { deployer } from "./accounts"; const rpcUrls: Record = { [sepolia.id]: process.env.SEPOLIA_RPC_URL, [base.id]: process.env.BASE_RPC_URL, [optimism.id]: process.env.OP_RPC_URL, }; export const clientsFor = (chain: Chain) => { const rpcUrl = rpcUrls[chain.id]; // http() with no url silently falls back to the chain's public RPC, which is not // something a deploy should do by accident. if (rpcUrl === undefined) throw new Error(`No RPC URL for chain ${chain.id} (${chain.name})`); const transport = http(rpcUrl); return { walletClient: createWalletClient({ account: deployer, chain, transport }), publicClient: createPublicClient({ chain, transport }), }; }; ``` ```ts import { sepolia } from "viem/chains"; import { clientsFor } from "../clients"; import { getOrDeployCounter } from "../deployers"; const { contract, freshDeploy } = await getOrDeployCounter({ ...clientsFor(sepolia), args: [0n], }); ``` ```bash tsx scripts/deploy.ts ``` * βœ… Address, ABI, constructor args, transaction, and compiler settings recorded to `deployments/-/Counter.json` * βœ… Run it again and you get the recorded contract back with no transaction sent, and `freshDeploy` tells you which happened * βœ… The signer is any viem `Account`: a keystore, AWS or GCP KMS, [Turnkey](/recipes/turnkey), [Privy](/recipes/privy), [Openfort](/recipes/openfort), a Ledger, or a JSON-RPC account where no key exists in the process ## Deploying to many chains ### Hardhat v2 Every chain is another entry, and another key wired in before any script can use it. ```js networks: { sepolia: { url: process.env.SEPOLIA_RPC_URL, accounts: [process.env.SEPOLIA_PRIVATE_KEY] }, base: { url: process.env.BASE_RPC_URL, accounts: [process.env.BASE_PRIVATE_KEY] }, optimism: { url: process.env.OP_RPC_URL, accounts: [process.env.OP_PRIVATE_KEY] }, } ``` ```bash #!/usr/bin/env bash set -euo pipefail for net in sepolia base optimism; do npx hardhat run scripts/deploy.ts --network "$net" done ``` ```bash ./deploy-all.sh ``` * 🚫 `hre.network` is a single connection fixed at startup, so one process cannot reach two chains * 🚫 Fifteen chains means fifteen invocations sequenced by a shell * 🚫 Nothing carries across those invocations, so anything one chain needs to know about another is threaded through by hand ### Hardhat v3 Every chain is another entry, and another key wired in before any script can use it. ```ts networks: { sepolia: { type: "http", chainType: "l1", url: configVariable("SEPOLIA_RPC_URL"), accounts: [configVariable("SEPOLIA_PRIVATE_KEY")] }, base: { type: "http", chainType: "op", url: configVariable("BASE_RPC_URL"), accounts: [configVariable("BASE_PRIVATE_KEY")] }, optimism: { type: "http", chainType: "op", url: configVariable("OP_RPC_URL"), accounts: [configVariable("OP_PRIVATE_KEY")] }, } ``` `network.create()` takes a network name, and connections are independent, so one script can reach all three. No shell loop. ```ts import { network } from "hardhat"; for (const target of ["sepolia", "base", "optimism"]) { const connection = await network.create(target); const counter = await connection.viem.deployContract("Counter", [0n]); console.log(connection.networkName, counter.address); await connection.close(); } ``` Keep the loop sequential. Opening the first connections concurrently races the lazily built network manager, and each racer ends up with its own. ```bash npx hardhat run scripts/deploy-all.ts ``` This is the big v2 to v3 change, and it closes most of the gap. `--network` is omitted on purpose: it only sets what a bare `network.create()` resolves to, and a script that names its own networks ignores it. * 🚫 Each chain still has to be declared in config before a script can reach it * 🚫 The addresses are still only console output, so nothing records what landed where * 🚫 Still no idempotency, so re-running redeploys all three ### deployoor ```ts import { base, optimism, sepolia } from "viem/chains"; import { clientsFor } from "../clients"; import { getOrDeployCounter } from "../deployers"; for (const chain of [sepolia, base, optimism]) { await getOrDeployCounter({ ...clientsFor(chain), args: [0n] }); } ``` Same `accounts.ts` and `clients.ts` as the single-chain deploy. Only the loop is new. ```bash tsx scripts/deploy.ts ``` * βœ… The same file and the same command as one chain * βœ… One process, so a later chain can read what an earlier one produced * βœ… Each deploy lands in its own `deployments/-/` [`examples/multi-chain`](https://github.com/raycashxyz/deployoor/tree/main/examples/multi-chain) does two chains and wires them together as LayerZero peers in a single file. ## Running ops scripts ### Hardhat v2 Plus `hardhat.config.js`, from setup. ```ts import hre from "hardhat"; const counter = await hre.viem.getContractAt("Counter", process.env.COUNTER_ADDRESS as `0x${string}`); await counter.write.increment(); ``` ```bash npx hardhat run scripts/inc.ts --network sepolia ``` * 🚫 The script starts by finding the address again, and it is only as correct as whatever was last pasted in * 🚫 It has to run through Hardhat, so there is a runtime to boot before anything happens ### Hardhat v3 Plus `hardhat.config.ts`, from setup. ```ts import { network } from "hardhat"; const { viem } = await network.create(); const counter = await viem.getContractAt("Counter", process.env.COUNTER_ADDRESS as `0x${string}`); await counter.write.increment(); ``` ```bash npx hardhat run scripts/inc.ts --network sepolia ``` * 🚫 Same address hunt, because nothing recorded it in the first place * 🚫 Same runtime, because the script still runs through Hardhat ### deployoor ```ts import { sepolia } from "viem/chains"; import { writeCounterIncrement } from "../src/generated"; import { config } from "../src/wagmi"; await writeCounterIncrement(config, { chainId: sepolia.id }); ``` ```bash tsx scripts/inc.ts ``` * βœ… No address to find, because the record already knows it * βœ… The ops script uses the same generated bindings as the frontend * βœ… Plain Node, no runtime to boot ## Handing deployments to the frontend team ### Hardhat v2 and v3 Plus the `hardhat.config` from setup, in either version. ```ts // maintained by hand export const counterAddress = { 11155111: "0x...", // sepolia 8453: "0x...", // base } as const; export const counterAbi = [/* pasted from artifacts/contracts/Counter.sol/Counter.json */] as const; ``` Identical in both versions, because neither produces anything for this. * 🚫 There is no command, so someone copies the address out of a script's output and the ABI out of `artifacts/` * 🚫 Redeploy and every copy goes stale without saying so ### deployoor ```ts import { defineConfig } from "@wagmi/cli"; import { actions } from "@wagmi/cli/plugins"; import { deployments } from "@deployoor/wagmi"; export default defineConfig({ out: "src/generated.ts", // react() instead of actions() emits hooks; both read the same records. plugins: [deployments({ path: "./deployments" }), actions()], }); ``` ```bash npx wagmi generate ``` * βœ… The record is the handoff format, so nothing is copied * βœ… One contract on several chains becomes a single entry with a `chainId` to address map * βœ… The generated file imports `viem` and `@wagmi/core`, never deployoor, so the frontend takes no dependency on your deploy tooling See [consumption](/guides/consumption). ## Testing ### Hardhat v2 Plus `hardhat.config.js`, from setup. ```ts import hre from "hardhat"; import { expect } from "chai"; describe("Counter", () => { it("increments", async () => { const counter = await hre.viem.deployContract("Counter", [0n]); await counter.write.increment(); expect(await counter.read.number()).to.equal(1n); }); }); ``` ```bash npx hardhat test ``` * 🚫 Tests run inside Hardhat and take their chain, accounts, and fixtures from it * 🚫 The test deploys through `hre.viem.deployContract`, while production deploys through `scripts/deploy.ts`, which you wrote * 🚫 Those are two code paths, and the one carrying money is the one without a test ### Hardhat v3 Plus `hardhat.config.ts`, from setup. ```ts import { network } from "hardhat"; import { expect } from "chai"; describe("Counter", () => { it("increments", async () => { const { viem } = await network.create(); const counter = await viem.deployContract("Counter", [0n]); await counter.write.increment(); expect(await counter.read.number()).to.equal(1n); }); }); ``` ```bash npx hardhat test ``` * 🚫 Still two code paths: `viem.deployContract` in the test, your own script in production * 🚫 Still inside the Hardhat runtime, so the test cannot run under a plain test runner ### deployoor ```ts import { describe, it, expect } from "vitest"; import { createTestClients } from "@deployoor/testing"; import { getOrDeployCounter } from "../deployers"; describe("Counter", () => { it("increments", async () => { const clients = await createTestClients(); // in-memory EVM plus an ephemeral store const { contract } = await getOrDeployCounter({ ...clients, args: [0n] }); await contract.write.increment(); expect(await contract.read.number()).toBe(1n); }); }); ``` ```bash vitest ``` * βœ… The deployer you ship is the deployer under test, so only the clients change * βœ… Any runner, no Hardhat, no node * βœ… Test deploys use an ephemeral store and never touch your committed records * βœ… Point the same call at `http(rpcUrl)` and it becomes a live-network test, including against a fork See [testing](/guides/testing). ## Signing with something other than a private key ### Hardhat v2 ```js require("@nomicfoundation/hardhat-ledger"); module.exports = { networks: { sepolia: { url: process.env.SEPOLIA_RPC_URL, ledgerAccounts: ["0xa809931e3b38059adae9bc5455bc567d0509ab92"], }, }, }; ``` Install the v2 line explicitly: `npm i -D @nomicfoundation/hardhat-ledger@hh2`. v2 exposes a hook for wrapping the provider, which is how the hosted-wallet plugins that exist were built. ```js const { extendProvider } = require("hardhat/config"); extendProvider(async (provider) => new MyCustodialProvider(provider)); ``` * βœ… Ledger is first-party and works here * βœ… `accounts: "remote"` hands signing to the node, which covers Frame, Clef, or a custodial node * βœ… `extendProvider` is a real escape hatch, and `@fireblocks/hardhat-fireblocks` uses it * 🚫 Everything else means writing that wrapper yourself ### Hardhat v3 ```ts import hardhatLedgerPlugin from "@nomicfoundation/hardhat-ledger"; export default { plugins: [hardhatLedgerPlugin], networks: { sepolia: { type: "http", url: configVariable("SEPOLIA_RPC_URL"), ledgerAccounts: ["0xa809931e3b38059adae9bc5455bc567d0509ab92"], }, }, }; ``` Ledger is the one non-key signer Nomic ships. It does not support EIP-7702. * βœ… Ledger is first-party here too * 🚫 `extendProvider` was removed in v3. It is a stub that throws and points you at `definePlugin`, so a custom signer now means authoring a plugin with network hook handlers * 🚫 `@fireblocks/hardhat-fireblocks` deep-imports `hardhat/internal/*` and does not work on v3. Its "Add Hardhat V3 support" issue has had no maintainer reply since November 2025 * 🚫 Privy, Turnkey, Dfns and Openfort publish no Hardhat plugin at all * 🚫 The maintained AWS and GCP KMS plugins are all v2, and the busiest gets around 125 downloads a week `accounts` is a closed union in both versions: `"remote"`, an array of private keys, or an HD mnemonic. In v3 the array is `SensitiveString[]`, which resolves from an encrypted keystore, but it is still a private key. There is no slot for an object. ### deployoor ```ts // run with `tsx scripts/deploy.ts` import { PrivyClient } from "@privy-io/node"; import { createViemAccount } from "@privy-io/node/viem"; import { createPublicClient, createWalletClient, http, type Address } from "viem"; import { sepolia } from "viem/chains"; import { getOrDeployCounter } from "../deployers"; const privy = new PrivyClient({ appId: process.env.PRIVY_APP_ID as string, appSecret: process.env.PRIVY_APP_SECRET as string, }); const deployer = await createViemAccount(privy, { walletId: process.env.PRIVY_WALLET_ID as string, address: process.env.PRIVY_WALLET_ADDRESS as Address, }); const transport = http(process.env.RPC_URL); const { contract } = await getOrDeployCounter({ walletClient: createWalletClient({ account: deployer, chain: sepolia, transport }), publicClient: createPublicClient({ chain: sepolia, transport }), args: [0n], }); ``` ```ts // run with `tsx scripts/deploy.ts` import { ApiKeyStamper } from "@turnkey/api-key-stamper"; import { TurnkeyClient } from "@turnkey/http"; import { createAccount } from "@turnkey/viem"; import { createPublicClient, createWalletClient, http } from "viem"; import { sepolia } from "viem/chains"; import { getOrDeployCounter } from "../deployers"; const httpClient = new TurnkeyClient( { baseUrl: "https://api.turnkey.com" }, new ApiKeyStamper({ apiPublicKey: process.env.TURNKEY_API_PUBLIC_KEY as string, apiPrivateKey: process.env.TURNKEY_API_PRIVATE_KEY as string, }), ); const deployer = await createAccount({ client: httpClient, organizationId: process.env.TURNKEY_ORGANIZATION_ID as string, signWith: process.env.TURNKEY_SIGN_WITH as string, }); const transport = http(process.env.RPC_URL); const { contract } = await getOrDeployCounter({ walletClient: createWalletClient({ account: deployer, chain: sepolia, transport }), publicClient: createPublicClient({ chain: sepolia, transport }), args: [0n], }); ``` Whole file, both times, so you can read the difference straight off: only the account block changes. Everything from `const transport` down is identical, which is why a real project lifts the top half into `accounts.ts` and the bottom half into `clients.ts` and never writes either again. * βœ… The signer is an argument, so there is nothing to configure and no plugin to wait for * βœ… Anything that produces a viem account works, including every vendor above, and a Ledger behind viem's `toAccount`. See the [recipes](/recipes) * βœ… A JSON-RPC account works too, where no key ever enters your process Dfns document this split themselves. Their own solution guides list the stack as Hardhat 3 for the contracts, then `tsx` plus viem plus `@dfns/sdk` for signing, because their KMS cannot reach a Hardhat deploy any other way. ## What the file lists say Both projects hold the same contracts, the same tests, a config, and a `.env`. The difference is not how many files there are, it is what they carry. ```text hardhat/ deployoor/ β”œβ”€β”€ contracts/Counter.sol β”œβ”€β”€ contracts/Counter.sol β”œβ”€β”€ hardhat.config.ts ← chains+keys β”œβ”€β”€ hardhat.config.ts ← solc only β”œβ”€β”€ .env β”œβ”€β”€ .env β”œβ”€β”€ scripts/deploy.ts β”œβ”€β”€ accounts.ts ← who signs β”œβ”€β”€ scripts/inc.ts β”œβ”€β”€ clients.ts ← chain in, clients out β”œβ”€β”€ deploy-all.sh ← to loop β”œβ”€β”€ scripts/{deploy,inc}.ts β”œβ”€β”€ test/counter.test.ts β”œβ”€β”€ test/counter.test.ts └── src/contracts.ts ← by hand β”œβ”€β”€ wagmi.config.ts └── deployments/ ← generated ``` The same `hardhat.config.ts` exists on both sides, because both compile with Hardhat. On the left it also holds every chain you can reach and the keys that reach them; on the right it holds a Solidity version. `deploy-all.sh` exists only because the CLI takes one `--network` at a time. `src/contracts.ts` is an address book somebody keeps up to date by hand, and goes stale silently when they forget. `accounts.ts` and `clients.ts` are the same wiring as the left column's config stanza, written as two small modules you own and import. The difference is that a script can pick a chain at runtime, and swapping custody is an edit to one file rather than a plugin you wait for. The other three carry state the tool does not record: which chains exist, which keys reach them, and where the last deploy ended up. deployoor writes that state to `deployments/` as it goes, so the chain list becomes an argument, the shell script becomes a loop, and the address book is generated. What is left in each job is one script, which is code you would have written anyway. # Hardhat Ignition Hardhat's official deploy tool. You describe a deployment as a module and run it through the CLI. The same project built twice: a Counter deployed to a testnet, deployed again across several chains, incremented by an ops script, covered by a test, and handed to a frontend. Each job below lists the files it touches. That file list is the comparison. ## Minimum setup ### Hardhat Ignition Networks and signers are declared to the framework. ```ts import { configVariable } from "hardhat/config"; import hardhatIgnitionViemPlugin from "@nomicfoundation/hardhat-ignition-viem"; export default { plugins: [hardhatIgnitionViemPlugin], solidity: "0.8.24", networks: { sepolia: { type: "http", chainType: "l1", // l1 | op | generic url: configVariable("SEPOLIA_RPC_URL"), accounts: [configVariable("SEPOLIA_PRIVATE_KEY")], }, }, }; ``` The deployment is a module of futures rather than statements. ```ts import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; export default buildModule("CounterModule", (m) => { const counter = m.contract("Counter", [0n]); m.call(counter, "inc"); return { counter }; }); ``` Nothing in that module is checked against your ABI by TypeScript. From the shipped types: ```ts contract(contractName: ContractNameT, args?: ArgumentType[], …) call(contractFuture, functionName: FunctionNameT, args?: ArgumentType[], …) type ArgumentType = BaseArgumentType | ArgumentType[] | { [field: string]: ArgumentType } ``` Ignition validates futures against your compiled artifacts during deployment planning, so a misspelled contract or function name is caught before any transaction goes out. The difference is when: a validation error on the deploy run, rather than a red squiggle in your editor or a failing `tsc` in CI. ```bash npm install --save-dev hardhat @nomicfoundation/hardhat-ignition-viem npx hardhat compile ``` * 🚫 Two files and a CLI flag before the first contract goes out * 🚫 Contract names, function names, and args are strings and `ArgumentType[]`, so the editor cannot check them * 🚫 Accounts are reached by index, `m.getAccount(accountIndex: number)`, and a module cannot branch on chain id ### deployoor No files. ```bash pnpm add -D deployoor npx hardhat compile # or `forge build` npx deployoor generate ``` * βœ… One typed function per contract, generated from the artifact * βœ… No module to author, no network list, no accounts stanza Args are checked against the constructor and methods against the ABI. This block is type-checked when these docs build, so hover `CounterArgs`, and note that both errors are real `tsc` output: ```ts twoslash // @errors: 2322 2339 import { defineDeployer } from "deployoor"; import type { PublicClient, WalletClient } from "viem"; const counterArtifact = { name: "Counter", abi: [ { type: "constructor", inputs: [{ name: "initial", type: "uint256" }], stateMutability: "nonpayable" }, { type: "function", name: "inc", inputs: [], outputs: [], stateMutability: "nonpayable" }, ], bytecode: "0x60", deployedBytecode: "0x60", metadata: { fullyQualifiedName: "src/Counter.sol:Counter", compilerVersion: "0.8.24", standardJsonInput: { language: "Solidity", sources: {}, settings: {} }, libraryPlaceholders: {}, }, } as const; const getOrDeployCounter = defineDeployer(counterArtifact, {}); declare const walletClient: WalletClient; declare const publicClient: PublicClient; // ---cut--- // `args` comes from Counter's constructor, not from a generic array: type CounterArgs = Parameters[0]["args"]; // ^? const { contract } = await getOrDeployCounter({ walletClient, publicClient, args: ["1"] }); await contract.write.inkrement(); ``` ## Deploying to one chain ### Hardhat Ignition Optional. Most people use the CLI, but a script still needs the Hardhat runtime present. ```ts import { network } from "hardhat"; import CounterModule from "../ignition/modules/Counter.js"; const connection = await network.create(); const { counter } = await connection.ignition.deploy(CounterModule); console.log("Counter:", counter.address); ``` ```bash npx hardhat ignition deploy ./ignition/modules/Counter.ts --network sepolia ``` * 🚫 The chain is chosen outside the code, by a CLI flag * 🚫 State goes to `ignition/deployments/chain-/`, and that deployment id is permanently bound to one chain. Pointing it at another errors with `HHE10900` * 🚫 The address and the ABI land in two different files, so neither is usable on its own ### deployoor ```ts import { sepolia } from "viem/chains"; import { clientsFor } from "../clients"; // your viem clients import { getOrDeployCounter } from "../deployers"; const { contract, freshDeploy } = await getOrDeployCounter({ ...clientsFor(sepolia), args: [0n], }); // m.call becomes an ordinary call, guarded so it only runs on a fresh deploy if (freshDeploy) await contract.write.inc(); ``` ```bash tsx scripts/deploy.ts ``` * βœ… A plain Node program: you build the signer and the chain, and `m.call` becomes an ordinary call guarded by `freshDeploy` * βœ… Address, ABI, args, transaction, and compiler settings land in one record * βœ… `account` is any viem `Account`: a keystore, AWS or GCP KMS, [Turnkey](/recipes/turnkey), [Privy](/recipes/privy), [Openfort](/recipes/openfort), a Ledger, or a JSON-RPC account where no key exists in the process ## Deploying to many chains ### Hardhat Ignition Possible on Hardhat 3, which can hold connections to several chains in one process. ```ts import { network } from "hardhat"; import CounterModule from "../ignition/modules/Counter.js"; for (const target of ["sepolia", "base", "optimism"]) { const connection = await network.create(target); const { counter } = await connection.ignition.deploy(CounterModule); console.log(connection.networkName, counter.address); await connection.close(); } ``` ```bash npx hardhat run scripts/deploy-all.ts ``` * 🚫 Each chain still has to be declared in config, and each gets its own deployment id * 🚫 Modules cannot branch on chain id, so anything that differs per chain lives outside the module * 🚫 There is no official multi-chain example * 🚫 Hardhat 2 cannot do it in one run at all ### deployoor ```ts import { base, mainnet, optimism } from "viem/chains"; import { clientsFor } from "../clients"; // your viem clients import { getOrDeployCounter } from "../deployers"; for (const chain of [mainnet, base, optimism]) { await getOrDeployCounter({ ...clientsFor(chain), args: [0n] }); } ``` Same `clients.ts` as the single-chain deploy. Only the loop is new. ```bash tsx scripts/deploy.ts ``` * βœ… The same file and the same command as one chain * βœ… Each deploy lands in its own `deployments/-/` * βœ… One process, so per-chain differences are just branches, and a later chain can read what an earlier one produced [`examples/multi-chain`](https://github.com/raycashxyz/deployoor/tree/main/examples/multi-chain) deploys across two chains and wires them as LayerZero peers in one file. ## Running ops scripts ### Hardhat Ignition The intended route: post-deploy calls belong in the module, reconciled on the next run. ```ts export default buildModule("CounterModule", (m) => { const counter = m.contract("Counter", [0n]); m.call(counter, "inc"); return { counter }; }); ``` A one-off transaction means dropping out of Ignition entirely. ```ts import { network } from "hardhat"; const connection = await network.create(); const counter = await connection.viem.getContractAt("Counter", process.env.COUNTER_ADDRESS as `0x${string}`); await counter.write.inc(); ``` ```bash npx hardhat run scripts/inc.ts --network sepolia ``` * 🚫 A one-off call is either a permanent module entry or a script outside the tool * 🚫 The script route finds the address again, because the address and ABI were never joined * 🚫 Either way it runs through the Hardhat runtime ### deployoor ```ts import { base } from "viem/chains"; import { writeCounterInc } from "../src/generated"; // from `wagmi generate` import { config } from "../src/wagmi"; await writeCounterInc(config, { chainId: base.id }); ``` ```bash tsx scripts/inc.ts ``` * βœ… The record holds the address, so an ops script uses the same bindings as the frontend * βœ… Plain Node, no runtime to boot ## Handing deployments to the frontend team ### Hardhat Ignition Nothing ships for this, so the join is yours to write and maintain. ```ts // maintained by hand: addresses from one file, ABIs from another import addresses from "../ignition/deployments/chain-11155111/deployed_addresses.json"; import counterArtifact from "../ignition/deployments/chain-11155111/artifacts/CounterModule#Counter.json"; export const counterAddress = addresses["CounterModule#Counter"] as `0x${string}`; export const counterAbi = counterArtifact.abi; ``` * 🚫 Addresses sit in `deployed_addresses.json` keyed `Module#Future`, ABIs sit in `artifacts/#.json`, and joining them is your job * 🚫 Multi-chain means repeating that per chain folder * 🚫 The words `wagmi`, `frontend`, and `typed` appear nowhere in the Ignition docs ### deployoor ```ts import { defineConfig } from "@wagmi/cli"; import { actions } from "@wagmi/cli/plugins"; import { deployments } from "@deployoor/wagmi"; export default defineConfig({ out: "src/generated.ts", plugins: [deployments({ path: "./deployments" }), actions()], // or react() for hooks }); ``` ```bash npx wagmi generate ``` * βœ… The record already holds the address and the ABI, so there is no join to write * βœ… One contract across several chains folds into a single entry with a `chainId` to address map * βœ… The generated file imports `viem` and `@wagmi/core`, never deployoor See [consumption](/guides/consumption). ## Testing ### Hardhat Ignition ```ts import { network } from "hardhat"; import CounterModule from "../ignition/modules/Counter.js"; const { ignition } = await network.create(); const { counter } = await ignition.deploy(CounterModule); ``` ```bash npx hardhat test ``` * 🚫 The in-memory chain, the accounts, and the fixture helpers all belong to Hardhat * 🚫 A test cannot reach an undeclared chain, and cannot run outside the framework * 🚫 Live-network testing works the same way from the other end, through `--network sepolia` and the same config ### deployoor ```ts import { describe, it, expect } from "vitest"; import { createTestClients } from "@deployoor/testing"; import { getOrDeployCounter } from "../deployers"; describe("Counter", () => { it("increments", async () => { const clients = await createTestClients(); // in-memory EVM plus an ephemeral store const { contract } = await getOrDeployCounter({ ...clients, args: [0n] }); await contract.write.inc(); expect(await contract.read.count()).toBe(1n); }); }); ``` ```bash vitest ``` * βœ… Any runner, no Hardhat, no node * βœ… The deployer under test is the deployer you ship, so only the clients change * βœ… Test deploys use an ephemeral [store](/concepts/deployment-stores) and never touch your committed records See [testing](/guides/testing). Coming from Ignition? See [migrate from Hardhat Ignition](/migrate/hardhat-ignition). # hardhat-deploy The tool that recognised what plain Hardhat was missing. It added committed deployment records, idempotent redeploys, named accounts, and proxy support, the same gaps that motivated deployoor, solved years earlier and in more depth. On proxies, diamonds, and deterministic addresses it is still further along. The difference is where it solved them. v2 is a Hardhat plugin built on [rocketh](https://rocketh.dev), so artifacts, networks, and accounts still arrive through a runtime that owns them. rocketh itself runs standalone, with its own CLI and no Hardhat, which makes it the closer comparison. There the coupling is not to a framework, but to the tool's own account and extension model. The same project built twice. Each job below lists the files it touches. ## Minimum setup ### hardhat-deploy Capabilities arrive as extensions spread into the config, viem among them. ```ts import * as deployExtension from "@rocketh/deploy"; import * as readExecuteExtension from "@rocketh/read-execute"; import * as viemExtension from "@rocketh/viem"; const extensions = { ...deployExtension, ...readExecuteExtension, ...viemExtension }; ``` Under the Hardhat plugin, networks still come from the framework. ```ts import "hardhat-deploy"; export default { solidity: "0.8.24", networks: { sepolia: { url: process.env.SEPOLIA_RPC_URL, accounts: [process.env.SEPOLIA_PRIVATE_KEY], }, }, }; ``` ```bash npm install --save-dev hardhat-deploy @rocketh/deploy @rocketh/read-execute @rocketh/viem npx hardhat compile ``` * 🚫 An extensions list before the first contract goes out * 🚫 viem is granted through `@rocketh/viem` rather than owned by you, so a new viem release is usable once that package ships support for it * 🚫 Under the Hardhat plugin the chain list is still framework config ### deployoor No files. ```bash pnpm add -D deployoor npx hardhat compile # or `forge build` npx deployoor generate ``` * βœ… Nothing to configure before the first deploy: no extensions to spread, no accounts stanza * βœ… viem is your dependency, so bumping it is the whole upgrade * βœ… No network list, because a chain is an argument ## Deploying to one chain ### hardhat-deploy ```ts const deployment = await deploy("Counter", { account: 0, // an index, or an address; not an object artifact: artifacts.Counter, args: [0n], }); ``` You get back a deployment record. Reads and writes then route through the environment, or through a viem wrapper you ask the environment for: ```ts await env.execute(Counter, { account: 0, // an index, or an address; not an object functionName: "inc", args: [], }); const contract = env.viem.getContract(deployment); await contract.read.count(); ``` `env` is present in every one of those lines. Custody is an extension point in config, not an argument at the call site. ```ts signerProtocols: { // the only implementation shipped privateKey: privateKeySigner, }, ``` ```bash npx hardhat deploy --network sepolia ``` * 🚫 The account is an index, an address, or a name, never an object. `AccountDefinition` is `EIP1193Account | string | number`, so a viem `Account` cannot reach a deploy call * 🚫 Custody instead goes through a `signerProtocols` entry returning an EIP-1193 shim. The extension point is real, and the only implementation shipped is `privateKey` * 🚫 `env` sits between you and the contract on every line ### deployoor ```ts import { sepolia } from "viem/chains"; import { clientsFor } from "../clients"; // your viem clients import { getOrDeployCounter } from "../deployers"; const { contract } = await getOrDeployCounter({ ...clientsFor(sepolia), args: [0n] }); await contract.write.inc(); ``` ```bash tsx scripts/deploy.ts ``` * βœ… The typed viem contract comes back on the first line, with no environment in the middle * βœ… Account and chain are properties of the client you passed, so the same function deploys from a test key, from CI, or from a Ledger on mainnet * βœ… Any viem `Account` works: a keystore, AWS or GCP KMS, [Turnkey](/recipes/turnkey), [Privy](/recipes/privy), [Openfort](/recipes/openfort), Fireblocks, hardware, or a JSON-RPC account where no key exists in the process ## Deploying to many chains ### hardhat-deploy ```bash #!/usr/bin/env bash set -euo pipefail # under Hardhat for net in sepolia base optimism; do npx hardhat deploy --network "$net" done # or, with rocketh's own CLI, one environment at a time for env in sepolia base optimism; do npx rocketh-deploy -e "$env" done ``` * 🚫 Under Hardhat the network comes from the Hardhat runtime, one per invocation * 🚫 rocketh's own CLI takes `-e `, again one at a time, with the environment naming a chain and its accounts * 🚫 Nothing carries across invocations ### deployoor ```ts import { base, mainnet, optimism } from "viem/chains"; import { clientsFor } from "../clients"; // your viem clients import { getOrDeployCounter } from "../deployers"; for (const chain of [mainnet, base, optimism]) { await getOrDeployCounter({ ...clientsFor(chain), args: [0n] }); } ``` Same `clients.ts` as the single-chain deploy. Only the loop is new. ```bash tsx scripts/deploy.ts ``` * βœ… The same file and the same command as one chain * βœ… Each deploy lands in its own `deployments/-/` * βœ… Records are keyed by chain id. hardhat-deploy keys folders by network name and keeps the chain id in a `.chain` sidecar, so the path alone does not tell you the chain ## Running ops scripts ### hardhat-deploy Through the environment, with the account given as an index. ```ts await env.execute(Counter, { account: 0, // an index, or an address; not an object functionName: "inc", args: [], }); ``` * 🚫 The environment has to exist before you can touch a contract * 🚫 The function name is a string in an options bag rather than a method on the contract * 🚫 The frontend gets a different shape than this, so ops code and app code diverge ### deployoor ```ts import { base } from "viem/chains"; import { writeCounterInc } from "../src/generated"; // from `wagmi generate` import { config } from "../src/wagmi"; await writeCounterInc(config, { chainId: base.id }); ``` ```bash tsx scripts/inc.ts ``` * βœ… Through the same generated bindings your frontend uses * βœ… No environment, and no address to look up ## Handing deployments to the frontend team ### hardhat-deploy First-party and good. `rocketh-export` emits an `as const` object: ```ts // generated by: rocketh-export -e --ts src/contracts.ts export default { chain: { id: 11155111, name: "sepolia" }, name: "sepolia", contracts: { Counter: { abi: [...], address: "0x...", startBlock: 1234567 }, }, } as const; ``` ```bash npx rocketh-export -e sepolia --ts src/contracts.ts ``` * βœ… This one is genuinely good, and it is first-party * 🚫 It is the tool's own format, so consuming it means writing the glue between that shape and whatever your app expects ### deployoor ```ts import { defineConfig } from "@wagmi/cli"; import { actions } from "@wagmi/cli/plugins"; import { deployments } from "@deployoor/wagmi"; export default defineConfig({ out: "src/generated.ts", plugins: [deployments({ path: "./deployments" }), actions()], // or react() for hooks }); ``` ```bash npx wagmi generate ``` * βœ… The codegen is the wagmi team's, so the output is what a wagmi or viem app already expects * βœ… No glue layer between the tool's format and your app See [consumption](/guides/consumption). ## Testing ### hardhat-deploy Deployment fixtures run through the environment, and under Hardhat the chain comes from Hardhat. ```ts const env = await loadEnvironment({ provider }); // whole-run override await env.fixtures.Counter(); ``` * 🚫 Under Hardhat, the chain, the accounts, and the fixtures all belong to Hardhat * 🚫 rocketh accepts a whole-run `provider` override, documented as a test idiom, which replaces the transport for the entire run rather than per call ### deployoor ```ts import { describe, it, expect } from "vitest"; import { createTestClients } from "@deployoor/testing"; import { getOrDeployCounter } from "../deployers"; describe("Counter", () => { it("increments", async () => { const clients = await createTestClients(); // in-memory EVM plus an ephemeral store const { contract } = await getOrDeployCounter({ ...clients, args: [0n] }); await contract.write.inc(); expect(await contract.read.count()).toBe(1n); }); }); ``` ```bash vitest ``` * βœ… Same function as production, different clients, chosen per call * βœ… Any runner, no Hardhat, no node * βœ… Test deploys use an ephemeral [store](/concepts/deployment-stores) and never touch your committed records See [testing](/guides/testing). Coming from hardhat-deploy? See [migrate from hardhat-deploy](/migrate/hardhat-deploy). # Installation ## Requirements * **Node.js β‰₯ 18** for the core `deployoor` package and plugins * **Node.js β‰₯ 22** for `@deployoor/testing` (it runs on [EDR](https://github.com/NomicFoundation/edr), Hardhat's in-process EVM, which requires it) * A compiled Solidity project β€” **Hardhat** (`artifacts/`) or **Foundry** (`out/`) Generated deployers are **TypeScript** (`.ts`). Run deploy scripts with `tsx`, Bun, or vitest β€” not bare `node`. ## Install deployoor ```bash pnpm add -D deployoor viem # or npm install -D deployoor viem ``` `viem` is a peer dependency β€” you bring your own version (β‰₯ 2). ## Scaffold config ```bash npx deployoor init ``` This writes `deployoor.config.ts` if it does not exist. ## Optional packages | Package | Install when… | | --------------------------------- | ----------------------------------------------------------- | | `@deployoor/hardhat` | You want deployers to regenerate on every `hardhat compile` | | `@deployoor/wagmi` + `@wagmi/cli` | You want typed React hooks from `deployments/` | | `@deployoor/etherscan` | You want explorer verification on deploy | | `@deployoor/sourcify` | You want Sourcify verification (keyless) | | `@deployoor/slack` | You want Slack notifications on deploy | | `@deployoor/testing` | You want in-memory EVM tests without a local node | ```bash pnpm add -D @deployoor/etherscan @deployoor/wagmi @wagmi/cli @deployoor/testing ``` # Quickstart A minimal Foundry or Hardhat project from zero to a typed, idempotent deploy. ## 1. Compile ```bash forge build # or: npx hardhat compile ``` deployoor auto-detects Hardhat (`artifacts/`) or Foundry (`out/` + `out/build-info`). ## 2. Generate deployers ```bash npx deployoor generate ``` This writes `./deployers/`, one typed `getOrDeploy` per deployable contract. [Commit them](/concepts/version-control) β€” they hold a name and an abi, and read everything else from your artifacts at deploy time, so they are small and a fresh clone typechecks without running `generate` first. ```ts // deployers/Counter.ts, generated; do not edit by hand import { defineDeployer } from "deployoor"; import config from "../deployoor.config"; import { counterArtifact } from "./types/Counter"; export const getOrDeployCounter = defineDeployer(counterArtifact, config); ``` Call it from a script or test, and you get back a typed viem contract object: ```ts const { contract, freshDeploy } = await getOrDeployCounter({ walletClient, publicClient, args: [0n], }); await contract.write.increment(); ``` ## 3. Set up your wallet, once Two small modules that every script, test, and ops task will import. You write them one time. ```ts // accounts.ts import type { Hex } from "viem"; import { privateKeyToAccount } from "viem/accounts"; export const deployer = privateKeyToAccount(process.env.PK as Hex); ``` ```ts // clients.ts import { createPublicClient, createWalletClient, http, type Chain } from "viem"; import { baseSepolia, sepolia } from "viem/chains"; import { deployer } from "./accounts"; const rpcUrls: Record = { [sepolia.id]: process.env.SEPOLIA_RPC_URL, [baseSepolia.id]: process.env.BASE_SEPOLIA_RPC_URL, }; export const clientsFor = (chain: Chain) => { const rpcUrl = rpcUrls[chain.id]; // http() with no url silently falls back to the chain's public RPC, which is not // something a deploy should do by accident. if (rpcUrl === undefined) throw new Error(`No RPC URL for chain ${chain.id} (${chain.name})`); const transport = http(rpcUrl); return { walletClient: createWalletClient({ account: deployer, chain, transport }), publicClient: createPublicClient({ chain, transport }), }; }; ``` `privateKeyToAccount` is one option. Swap in an encrypted keystore, AWS or GCP KMS, [Turnkey](/recipes/turnkey), [Privy](/recipes/privy), or a Ledger, and `accounts.ts` is the only file that changes, because deployoor reaches your signer only through viem. It never sees a key, so it has no opinion about where yours lives. ## 4. Write a deploy script ```ts // scripts/deploy.ts import { sepolia } from "viem/chains"; import { deployer } from "../accounts"; import { clientsFor } from "../clients"; import { getOrDeployToken } from "../deployers"; const { contract: token, freshDeploy } = await getOrDeployToken({ ...clientsFor(sepolia), args: [deployer.address], }); if (freshDeploy) { console.log("Deployed fresh, run one-time setup here"); } await token.write.transfer([recipient, amount]); ``` ```bash tsx --env-file=.env scripts/deploy.ts ``` No deployoor CLI in this step, and no framework to boot: it is a Node program. Two things about that script are deliberate, and they are why the rest of these docs look the way they do: * **You built the account**, in `accounts.ts`, and every caller imports it rather than rebuilding it. * **You chose the chain.** `chain` and `transport` are arguments, not config entries, so a second chain is another `clientsFor` call and testing against a fork means changing the transport. Both are what make the next two sections possible: another call to `clientsFor` gives you a multi-chain deploy, and in-memory clients turn the identical `getOrDeployToken` call into a test. ## Multi-chain deploy (LayerZero Ping / Pong) One script can target **multiple networks**, one `clientsFor` call per chain. deployoor records each deploy under its own `deployments/-/` folder. This example deploys **Ping** on Sepolia and **Pong** on Base Sepolia, then links them as LayerZero peers: ```ts // scripts/deploy.ts, full version in examples/multi-chain/ import { pad, type Address } from "viem"; import { baseSepolia, sepolia } from "viem/chains"; import { clientsFor } from "../clients"; import { getOrDeployPing, getOrDeployPong } from "../deployers"; const LZ_ENDPOINT = "0x6EDCE65408990e3A38e31dE08b74da7D5258d898"; const LZ_EID = { sepolia: 40_161, baseSepolia: 40_245 } as const; const sepoliaClients = clientsFor(sepolia); const baseClients = clientsFor(baseSepolia); const { contract: ping } = await getOrDeployPing({ ...sepoliaClients, args: [LZ_ENDPOINT] }); const { contract: pong } = await getOrDeployPong({ ...baseClients, args: [LZ_ENDPOINT] }); const peer = (addr: Address) => pad(addr, { size: 32 }); await ping.write.setPeer([LZ_EID.baseSepolia, peer(pong.address)]); await pong.write.setPeer([LZ_EID.sepolia, peer(ping.address)]); ``` ```bash # .env: PRIVATE_KEY, SEPOLIA_RPC_URL, BASE_SEPOLIA_RPC_URL pnpm --filter @example/multi-chain generate pnpm --filter @example/multi-chain deploy ``` Records land in: ``` deployments/ β”œβ”€ 11155111-sepolia/ β”‚ └─ Ping.json └─ 84532-base-sepolia/ └─ Pong.json ``` Runnable contracts + script: [`examples/multi-chain`](https://github.com/raycashxyz/deployoor/tree/main/examples/multi-chain). ## 5. Check the record Every deploy writes a committed JSON file: ``` deployments/ └─ 11155111-sepolia/ └─ Token.json ``` First run deploys and records. Later runs return the same contract with **no transaction**. ## What you get back `getOrDeploy` resolves to: ```ts { contract, // typed viem contract, read/write immediately freshDeploy, // true only when this call broadcast a deploy tx receipt, // deploy receipt (only on fresh deploy) deployment, // full DeploymentRecord } ``` Use `freshDeploy` to gate one-time setup (e.g. `initialize()`) only when the contract was actually deployed this run. # Deployment records Every deploy writes a JSON file to `deployments/-/.json`. This folder is the product β€” committed to git, readable by humans and any tool. ## Layout ```text deployments/ β”œβ”€ 11155111-sepolia/ β”‚ β”œβ”€ Token.json β”‚ └─ Vault.json β”œβ”€ 8453-base/ β”‚ └─ Token.json └─ sources/ └─ 0x8f3a….json ← pinned verification input, shared by every record that used it ``` The folder key is `chainId` plus a slugged chain name (e.g. viem's "Arbitrum One" β†’ `42161-arbitrum-one`). This prevents name collisions across chains. ## Record shape ```jsonc { "schemaVersion": 2, "contractName": "Token", "deploymentName": "Token", "address": "0x5FbDB2315678afecb367f032d93F642f64180aa3", "chainId": 11155111, "networkName": "11155111-sepolia", "abi": [/* full ABI */], "bytecode": "0x60806040...", "constructorArgs": ["0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"], "transactionHash": "0x2c9a...d4e1", "deployer": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "deployedAt": 1719849600000, "compiler": { "version": "0.8.24+commit.e11b9ed9", "settings": { "optimizer": { "enabled": true, "runs": 200 } }, }, "codeHash": "0x…", // keccak of the metadata-stripped runtime bytecode "identityHash": "0x…", // keccak(stripped runtime ++ args ++ libraries) "sourcesHash": "0x…", // β†’ deployments/sources/.json, the pinned verification input "history": [/* see below */], "kind": "standard", } ``` * `bigint` constructor args are stored as **strings** β€” plain, greppable JSON * `codeHash` is a 32-byte digest, not a second copy of the bytecode: enough to check that the code at an address is still the code you recorded, without keeping ~24KB of hex per chain in git. It is absent for a contract whose runtime bytecode still carries unlinked library placeholders, and it will not equal `keccak(eth_getCode(...))` for a contract with `immutable` variables, whose on-chain code has the values filled in. To reproduce the exact runtime bytecode, recompile the [pinned sources](#pinned-verification-sources) * `kind: "external"` for contracts recorded via `register` (not deployed by you) * `schemaVersion: 2` β€” v1 records still read; they upgrade in place on the next deploy ## History Every real (re)deploy appends to `history` (newest last), so the record β€” and its git diff β€” shows *why* each deployment happened. `identityHash` is the [deploy identity](/concepts/idempotency); `supersededAddress` links a redeploy to the address it replaced. ```jsonc "history": [ { "at": 1719849600000, "address": "0x5FbD…0aa3", "transactionHash": "0x2c9a…d4e1", "deployer": "0xf39F…2266", "identityHash": "0x…", "reason": { "kind": "fresh" }, "summary": "first deployment", }, { "at": 1719936000000, "address": "0xA1b2…C3d4", "transactionHash": "0x77aa…12ff", "deployer": "0xf39F…2266", "identityHash": "0x…", "reason": { "kind": "changed", "changes": [{ "field": "args", "from": ["0x…AAAA"], "to": ["0x…BBBB"], "changedIndices": [0] }] }, "summary": "constructor args changed from [0x…AAAA] to [0x…BBBB] (arg 1 `owner`)", "supersededAddress": "0x5FbD…0aa3", }, ] ``` ## Pinned verification sources Each deploy pins the exact solc standard-json input it used (sources, settings, compiler version, fully-qualified name) to `deployments/sources/.json`, and the record points at it with `sourcesHash`. That is everything a block explorer needs, kept independent of the current source tree. The file is named after a hash of its own contents, so one compilation input is stored **once** no matter how many chains or contracts reference it. A standard-json input is the whole compilation unit, so a per-record copy would mean a copy of your entire source tree per chain. Commit it with the records; see [version control](/concepts/version-control). ## Portability Records are vanilla JSON with no superjson or proprietary encoding. A Python, Go, or Rust service reads them the same way TypeScript does. Your app does not need deployoor at runtime. ## Filesystem guarantees * **Atomic writes** β€” records are written atomically under a coarse lock * **Chain ID guard** β€” reusing a record on the wrong chain fails with a clear error # Deployment stores A [deployment record](/concepts/deployment-records) is the data. A **store** is where that data lives. Every deployer reads and writes through a store. By default it is a folder of JSON files in your repo, which is what you want almost always. The store is an interface, though, so the same deployer can write to memory during a test, or to a database or an object store if your team keeps deployment state somewhere central. ```ts const { contract } = await getOrDeployCounter({ walletClient, publicClient, args: [0n] }); // read the store, and if nothing is recorded, deploy and write to it ``` ## The two built in Both are exported from `deployoor`. ### `fsStore` (default) JSON files on disk, one per contract, under `deployments/-/.json`. You do not construct it: a deployer builds one from `deploymentsPath` in your config, defaulting to `./deployments`. Commit that folder. It is the record of what your team has deployed, and it is what [`@wagmi/cli`](/guides/consumption) reads later. ### `memoryStore` The same interface backed by a `Map`, so nothing touches disk and nothing survives the process. ```ts import { memoryStore } from "deployoor"; await getOrDeployCounter({ walletClient, publicClient, args: [0n], store: memoryStore() }); ``` [`@deployoor/testing`](/guides/testing)'s `createTestClients()` includes one, so spreading `clients` into a call keeps test deploys off disk automatically. It also takes a seed, which is useful when a test needs to start from a contract that is already "deployed": ```ts const store = memoryStore([existingRecord]); ``` ## Choosing per call `store` is a call option, not global state, so one script can mix them: ```ts // real deploy, recorded to the repo await getOrDeployCounter({ walletClient, publicClient, args: [0n] }); // dry run against a fork, recording nowhere await getOrDeployCounter({ walletClient, publicClient, args: [0n], store: memoryStore() }); ``` ## Writing your own A store is four functions, plus optional ones. They can be sync or async. ```ts interface StoreAdapter { read: (network: string, name: string) => Awaitable; write: (record: DeploymentRecord) => Awaitable; list: (network: string) => Awaitable>; remove: (network: string, name: string) => Awaitable; lock?: (network: string, name: string) => Awaitable<() => Awaitable>; listAll?: () => Awaitable>; } ``` `network` is the same key the folder layout uses, `-`, built from the chain on your wallet client. `name` is the deployment name, which is the contract name unless you passed `deploymentName`. A store that keeps records in a table, an S3 bucket, or a config service only has to implement those. Nothing else in deployoor knows the difference. `listAll` is what a repo-wide command reaches for, since `list` is per-network and the adapter is not required to say which networks it holds. Omit it and [`deployoor verify`](/reference/cli#verify) has to be pointed at one network at a time, by its exact `-` key. ```ts import type { StoreAdapter } from "deployoor"; const s3Store = (bucket: string): StoreAdapter => ({ read: async (network, name) => (await getObject(bucket, `${network}/${name}.json`)) ?? null, write: (record) => putObject(bucket, `${record.networkName}/${record.deploymentName}.json`, record), list: (network) => listObjects(bucket, `${network}/`), remove: (network, name) => deleteObject(bucket, `${network}/${name}.json`), }); ``` ### About `lock` `read β†’ deploy β†’ write` is not atomic. If two CI jobs deploy the same contract to the same chain at the same moment, both can read "nothing recorded" and both can deploy. `lock` is how a store closes that window. It takes the same network and name, and returns a release function. Implement it if your backend can hold a mutual exclusion; leave it out and the store still works, it just has no protection against a concurrent deploy of the same contract. `fsStore` implements it with an exclusive lock file next to the record. A waiter retries for about thirty seconds, and a lock whose file is older than five minutes is treated as abandoned and reclaimed, so a crashed job does not block the next one forever. ## What `fsStore` guarantees Worth knowing if you are comparing it to a store of your own: * **Atomic writes.** A record is written to a temporary file and renamed into place, so a reader never sees a half-written file and a crash mid-write cannot corrupt an existing record. * **`bigint` is written as a string.** Records stay valid JSON that any tool can read. See [deployment records](/concepts/deployment-records). * **A damaged file is skipped, not fatal.** `list` ignores files it cannot parse rather than failing the whole run. * **Path segments are checked.** A network or deployment name containing `..` or a path separator is rejected instead of escaping the deployments folder. # Idempotency & redeployment `getOrDeploy` declares desired state: *this contract should exist on this network.* A recorded deployment is reused with no transaction; whether a **change** triggers a redeploy is set by `redeploymentStrategy`. ## Strategies | Strategy | Behavior | | ----------- | ------------------------------------------------------------------------------------ | | `on-change` | **Default.** Reuse the record unless the deploy identity changed, then redeploy. | | `never` | Always reuse an existing record; warn on drift. Deploy only when there is no record. | | `always` | Redeploy on every call β€” new address and tx. | Set it per call, or as a config default (globally or [per chain](/guides/configuration#options)): ```ts await getOrDeployToken({ ...clients, args: [owner], redeploymentStrategy: "always" }); ``` ## Deploy identity `on-change` compares the **deploy identity**, not the source: > identity = metadata-stripped **runtime bytecode** + **constructor args** + **linked library addresses** * A comment-only recompile changes solc's trailing metadata hash but not the identity β€” **no redeploy** (the CBOR metadata is stripped before comparing). * Changing a constructor arg **does** redeploy β€” including when that arg is a dependency's address. Redeploy a token and the vault that took its address redeploys too: **changes cascade through the stack**. * A changed linked-library address redeploys as well. Every comparison is canonical, never textual. Args are compared by their ABI encoding, so `1`, `1n`, and the `"1"` a record's JSON stores are one value, and an address matches in either casing β€” representation drift alone never redeploys. ### What the cascade does not cover The cascade follows **constructor args**, because that is what the identity contains. Two things it cannot see: * **Addresses installed after deployment.** If a contract learns its dependency through `setToken(addr)` rather than a constructor arg, redeploying that dependency does not move the dependent's identity. You have to call the setter yourself. * **State.** A redeploy is a new, empty contract β€” balances, ownership, and storage stay with the old one. ## Choosing a strategy `on-change` is built for the loop where contracts are still moving: edit, re-run the script, and what actually changed redeploys. Nothing is silent β€” every redeploy is logged loudly and appended to the record's [history](/concepts/deployment-records#history) with its reason (`contract bytecode changed`, `constructor args changed from […] to […]`, …), so the git diff says what moved and why. On a chain carrying **live state or real users**, pin `never`: ```ts export default defineConfig({ redeploymentStrategyByChainId: { [mainnet.id]: "never" }, }); ``` A redeploy does not destroy the old contract β€” it keeps its address, balances, and storage. But your record now names the new one, so anything still holding the old address (another deployed contract, a subgraph, a cached frontend build) is talking to a different contract than your app is. Undoing that means redeploying or rewiring the rest of the system, which is why the choice belongs in config per chain rather than in the moment. ## Stale-record warning (`never`) Under `never`, if the compiled bytecode or constructor args differ from the record, deployoor **warns** and reuses it β€” so drift never passes silently. Switch to `on-change` (the default) or `always` to act on it, or `reset()` to forget the record first. ## External vs deployed `register` records an address you did not deploy. It will not overwrite a real deployment at the same `(chain, deploymentName)` β€” reset first or pick a different name. External records are never redeployed. # Version control Commit everything deployoor writes. The `deployments/` record is the product, and the generated `deployers/` are what make a fresh clone typecheck and deploy without anyone knowing to run a command first. ```text deployments/ 8453-base/Token.json ← commit: the record β€” the source of truth 10-optimism/Token.json ← commit sources/0x8f3a….json ← commit: pinned verification input, shared by both records deployers/ Token.ts ← commit: the typed deployer types/Token.ts ← commit: its name + ABI index.ts ← commit ``` ## Commit `deployments/` Each `.json` is the source of truth β€” address, ABI, creation bytecode, compiler, and the [history](/concepts/deployment-records#history) of every (re)deploy. It is small, vanilla JSON, read by your app, [`@wagmi/cli`](/guides/consumption), humans, and any other tool. Its `sourcesHash` points into `deployments/sources/`, which pins the exact solc standard-json input used for that deploy β€” see [verifying later](#verifying-later). ## Commit `deployers/` too A generated deployer holds a name, a fully-qualified name, and an ABI. Everything else β€” creation bytecode, compiler version, the standard-json input β€” is read from your compiled artifacts at the moment you deploy, so none of it is copied into the file: ```ts // deployers/types/Token.ts const abi = [...] as const; export const tokenArtifact = { name: "Token", fullyQualifiedName: "contracts/Token.sol:Token", abi, } satisfies GeneratedArtifact; ``` ```ts // deployers/Token.ts export const getOrDeployToken = defineDeployer(tokenArtifact, config); ``` That is an ABI and four lines, so the file changes only when your contract's interface changes β€” which is a diff worth reading. Committing it means a clone can typecheck, run its tests, and deploy against the same interface the repo was written for. :::info Until 0.7 these files inlined the whole `standardJsonInput` β€” every source file in the compilation unit, per contract β€” which made them large, churn on every solc bump, and worth ignoring. That is why the old advice was the opposite. See [upgrading](/guides/upgrading). ::: The ABI is the one thing that stays inline, and it has to: a `.json` import widens `"uint256"` to `string`, which costs you `args` typing and `contract.read.*` entirely. Because it is a copy, it is also checked β€” a deploy compares it against the compiled artifact and refuses to run on drift rather than encoding your constructor arguments against an interface that no longer exists. ## What to ignore Your framework's build output, which it already ignores: `artifacts/` and `cache/` for Hardhat, `out/` and `cache/` for Foundry. deployoor reads those; it does not write them. If a rule in your `.gitignore` covers deployoor's output, both `deployoor generate` and `deployoor init` say so and offer to remove it β€” see [upgrading](/guides/upgrading#3-commit-deployers). ## Tell your formatter to skip them An ABI is emitted as one long line β€” 532 characters for a four-function contract, and it grows with the interface β€” so past a certain size every formatter wants to rewrite it, and regenerating then undoes the rewrite. A repo that both commits `deployers/` and checks formatting in CI then fails on files nothing hand-edits. Exclude the directory instead: ```text # .prettierignore deployers ``` ```jsonc // .oxlintrc.json { "ignorePatterns": ["deployers"] } ``` ESLint's flat config spells it differently, and has to be JS or TS rather than JSON: ```js // eslint.config.js export default [{ ignores: ["deployers/**"] }]; ``` ## Verifying later A record's pinned sources hold everything a standard-json block-explorer verify needs β€” the exact sources, settings, compiler version, and fully-qualified name used for that deploy. Committing them keeps the material for a later verification in the repo, so you never have to reconstruct the old source or find the commit it came from. They are **content-addressed**: the filename is a hash of the input, and the record references it by `sourcesHash`. A standard-json input is the whole compilation unit β€” every source file β€” so this is what keeps one contract deployed to six chains from meaning six copies of your entire source tree. Deploys that recompile nothing rewrite nothing, so the files stay out of your diffs. `reset` collects any pinned sources no remaining record references; blobs another chain still points at survive. [`deployoor verify`](/reference/cli#verify) is what reads them. It verifies a recorded deployment from its record and its pinned sources, with no recompile and no redeploy, however long after the fact β€” including contracts deployed before you had an API key, and contracts whose sources have since changed, because the blob that was pinned at deploy time is still the one the record points at. # Recipes deployoor asks for a viem `WalletClient`. It never asks where the key lives, so any wallet that can produce a viem account works, and swapping between them changes one import. ```ts const { contract } = await getOrDeployCounter({ walletClient, publicClient, args: [0n] }); ``` ## Why every viem wallet works viem accepts an account as long as it can sign. The full contract is an address plus three methods: ```ts // viem's own CustomSource, abridged to the parts that matter here import type { Address, Hex } from "viem"; type CustomSource = { address: Address; signMessage: (args: { message: unknown }) => Promise; signTransaction: (transaction: unknown, options?: { serializer?: unknown }) => Promise; signTypedData: (typedData: unknown) => Promise; }; ``` A deploy only ever calls `signTransaction`. Anything that can produce a secp256k1 signature over a hash can therefore back a deploy, whether the key sits in a local file, an HSM, or a vendor's TEE. Two shapes exist, and both work: * **Local accounts** sign in-process, then viem sends `eth_sendRawTransaction`. Vendor SDKs that return a viem account (Privy, Coinbase CDP, Turnkey, Dfns, KMS wrappers) all land here. * **JSON-RPC accounts** are just an address. viem sends `eth_sendTransaction` and the wallet signs, so no key material enters your process at all. A node, Frame, Clef, or an EIP-1193 provider like Fireblocks fits this shape. deployoor reads `walletClient.chain` and `walletClient.account.address`, then calls `deployContract`. That is the whole surface it touches, which is why none of the recipes below need anything from deployoor itself. ## Recipes * [Privy](/recipes/privy) β€” server wallets via `@privy-io/node` * [Coinbase CDP](/recipes/coinbase-cdp) β€” server accounts via `@coinbase/cdp-sdk` * [Turnkey](/recipes/turnkey) β€” API-key or passkey signing via `@turnkey/viem` * [Openfort](/recipes/openfort) β€” backend wallets via `@openfort/openfort-node` ## Anything else The same pattern covers wallets without a purpose-built recipe: | Wallet | How it reaches viem | | ---------------------- | ------------------------------------------------------------------ | | Local key or mnemonic | `privateKeyToAccount`, `mnemonicToAccount` from `viem/accounts` | | Encrypted keystore | decrypt at startup, then `privateKeyToAccount` | | AWS or GCP KMS | a `toAccount` wrapper that signs via the KMS API | | Ledger or Trezor | `@ledgerhq/hw-app-eth` behind `toAccount`, or an EIP-1193 provider | | Fireblocks | `@fireblocks/fireblocks-web3-provider`, then `custom(provider)` | | A node, Frame, or Clef | pass the address and let the provider sign | For KMS and hardware the shape is always the same: implement `signTransaction` so it hashes the serialized transaction, gets a signature from wherever the key lives, and re-serializes. ```ts import { toAccount } from "viem/accounts"; import { keccak256, serializeTransaction, hashMessage, hashTypedData, serializeSignature, type Hex, } from "viem"; // `sign` is whatever your KMS, HSM, or device exposes: a hash in, a signature out. declare function sign(hash: Hex): Promise<{ r: Hex; s: Hex; yParity: number }>; const account = toAccount({ address, // derived from the public key your signer exposes async signTransaction(tx, { serializer = serializeTransaction } = {}) { const { r, s, yParity } = await sign(keccak256(serializer(tx))); return serializer(tx, { r, s, yParity }); }, signMessage: ({ message }) => sign(hashMessage(message)).then(serializeSignature), signTypedData: (typedData) => sign(hashTypedData(typedData)).then(serializeSignature), }); ``` Signers need to be **secp256k1**. AWS KMS calls that `ECC_SECG_P256K1` and GCP calls it `EC_SIGN_SECP256K1_SHA256`; the default P-256 keys both offer will not work. ## Picking a signer for production A deploy key and a treasury key are different things. The usual pattern is to deploy from a throwaway EOA funded just for gas, then transfer ownership to a Safe or a timelock as the last scripted step, which is why a hot deploy key is normally acceptable: after handoff it controls nothing. Whatever you pick, the deploy script does not change. Only the account you construct does. # Privy [Privy](https://privy.io) server wallets keep the wallet's own signing key on their side and expose it as a viem account, so your process never holds the key that controls the funds. Depending on how the wallet is configured, it may still hold an authorization key, which is covered below. ## Install ```bash pnpm add @privy-io/node viem pnpm add -D deployoor tsx ``` ## The account `@privy-io/node/viem` turns a Privy wallet into a viem account: ```ts // accounts.ts import type { Address } from "viem"; import { PrivyClient } from "@privy-io/node"; import { createViemAccount } from "@privy-io/node/viem"; const privy = new PrivyClient({ appId: process.env.PRIVY_APP_ID as string, appSecret: process.env.PRIVY_APP_SECRET as string, }); export const deployer = await createViemAccount(privy, { walletId: process.env.PRIVY_WALLET_ID as string, address: process.env.PRIVY_WALLET_ADDRESS as Address, }); ``` Wallets that require an authorization context take one more field. Note what this changes: an authorization key **is** a private key, and this variant puts one in your process. ```ts export const deployer = await createViemAccount(privy, { walletId: process.env.PRIVY_WALLET_ID as string, address: process.env.PRIVY_WALLET_ADDRESS as Address, authorizationContext: { authorization_private_keys: [process.env.PRIVY_AUTHORIZATION_KEY as string], }, }); ``` :::warning `PRIVY_AUTHORIZATION_KEY` is a private key, not an API token. It does not control the wallet on its own, but it authorises wallet actions, so store it wherever you keep signing material rather than beside ordinary config, keep it out of source control, and rotate it on the same schedule as your other secrets. ::: ## The deploy ```ts // scripts/deploy.ts, run with `tsx scripts/deploy.ts` import { createPublicClient, createWalletClient, http } from "viem"; import { base } from "viem/chains"; import { deployer } from "../accounts"; import { getOrDeployCounter } from "../deployers"; const transport = http(process.env.RPC_URL); const clients = { walletClient: createWalletClient({ account: deployer, chain: base, transport }), publicClient: createPublicClient({ chain: base, transport }), }; const { contract, freshDeploy, deployment } = await getOrDeployCounter({ ...clients, args: [0n] }); console.log(freshDeploy ? `deployed at ${contract.address}` : `already at ${contract.address}`); console.log(`recorded to deployments/${deployment.networkName}/`); ``` `deployer` is a local account as far as viem is concerned: it signs the transaction, then viem broadcasts the raw result. Privy performs the signing, and your `PRIVY_APP_SECRET` never authorises anything beyond the wallet you named. ## Notes * On the basic flow your process holds an app secret and a wallet id, and no private key. The wallet id is not a secret and is not the boundary: `PRIVY_APP_SECRET` authorises action on any wallet and resource your app owns, so a leak reaches more than the one wallet you named. Keep it server-only, scope it, and rotate it like any other credential. * On the authorization-context flow it also holds `PRIVY_AUTHORIZATION_KEY`, which is a private key. Protect and rotate it as such. * The same `deployer` works in an ops script. Import it, build clients for the chain you want, and call the generated bindings. * For tests, keep using [`@deployoor/testing`](/guides/testing). There is no reason to spend Privy calls on an in-memory chain. # Coinbase CDP [CDP](https://docs.cdp.coinbase.com) server accounts implement viem's account interface directly, so `toAccount` is all that stands between a CDP account and a deploy. ## Install ```bash pnpm add @coinbase/cdp-sdk viem pnpm add -D deployoor tsx ``` ## The account ```ts // accounts.ts import { CdpClient } from "@coinbase/cdp-sdk"; import { toAccount } from "viem/accounts"; const cdp = new CdpClient(); // reads CDP_API_KEY_ID / CDP_API_KEY_SECRET / CDP_WALLET_SECRET // createAccount is idempotent when you name it, so this is safe to call on every run const cdpAccount = await cdp.evm.getOrCreateAccount({ name: "deployer" }); export const deployer = toAccount(cdpAccount); ``` `toAccount` comes from `viem/accounts`, not from the CDP SDK. CDP accounts already expose the `signMessage` / `signTransaction` / `signTypedData` surface viem expects, so wrapping them produces a viem Custom Account. ## The deploy ```ts // scripts/deploy.ts, run with `tsx scripts/deploy.ts` import { createPublicClient, createWalletClient, http } from "viem"; import { baseSepolia } from "viem/chains"; import { deployer } from "../accounts"; import { getOrDeployCounter } from "../deployers"; const transport = http(process.env.RPC_URL); const clients = { walletClient: createWalletClient({ account: deployer, chain: baseSepolia, transport }), publicClient: createPublicClient({ chain: baseSepolia, transport }), }; const { contract, freshDeploy } = await getOrDeployCounter({ ...clients, args: [0n] }); if (freshDeploy) await contract.write.inc(); ``` ## Multi-chain with one account A CDP account is not bound to a chain, so the same account deploys everywhere. Build a client per chain and loop: ```ts import { arbitrum, base, optimism } from "viem/chains"; const rpcUrls: Record = { [base.id]: process.env.BASE_RPC_URL, [optimism.id]: process.env.OP_RPC_URL, [arbitrum.id]: process.env.ARBITRUM_RPC_URL, }; for (const chain of [base, optimism, arbitrum]) { const rpcUrl = rpcUrls[chain.id]; // http() with no url silently falls back to the chain's public RPC. if (rpcUrl === undefined) throw new Error(`No RPC URL for chain ${chain.id}`); const transport = http(rpcUrl); await getOrDeployCounter({ walletClient: createWalletClient({ account: deployer, chain, transport }), publicClient: createPublicClient({ chain, transport }), args: [0n], }); } ``` Each deploy records under its own `deployments/-/`, so one run leaves you three committed records and three addresses your app can read. ## Notes * CDP has its own faucet for testnets, which pairs well with a first deploy to Base Sepolia. * Credentials live in `CDP_API_KEY_ID`, `CDP_API_KEY_SECRET`, and `CDP_WALLET_SECRET`. Both secrets are server-only: keep them out of browser bundles and out of the repo. The wallet secret is the one that authorises signing, so a leak of it is the worst case, but the API key secret authenticates every call and deserves the same handling. * `getOrCreateAccount` keyed by name means CI reuses the same deployer address across runs instead of creating a new one each time. Names are unique per CDP project, so that only holds for runs using the same project and credentials. # Turnkey [Turnkey](https://turnkey.com) keys live in a TEE and are reached through a stamped API call. `@turnkey/viem` wraps that as a viem account. ## Install ```bash pnpm add @turnkey/viem @turnkey/http @turnkey/api-key-stamper viem pnpm add -D deployoor tsx ``` ## The account ```ts // accounts.ts import { createAccount } from "@turnkey/viem"; import { TurnkeyClient } from "@turnkey/http"; import { ApiKeyStamper } from "@turnkey/api-key-stamper"; const httpClient = new TurnkeyClient( { baseUrl: "https://api.turnkey.com" }, new ApiKeyStamper({ apiPublicKey: process.env.TURNKEY_API_PUBLIC_KEY as string, apiPrivateKey: process.env.TURNKEY_API_PRIVATE_KEY as string, }), ); export const deployer = await createAccount({ client: httpClient, organizationId: process.env.TURNKEY_ORGANIZATION_ID as string, signWith: process.env.TURNKEY_SIGN_WITH as string, // wallet account address, private key id, or private key address }); ``` `signWith` names what signs. Passing an address that Turnkey already knows means you can skip `ethereumAddress`; supply it explicitly only when signing with a private key id whose address you want to pin. For a browser flow, install `@turnkey/webauthn-stamper` and swap `ApiKeyStamper` for its `WebauthnStamper`, and the account is passkey-backed instead. Nothing downstream changes. ## The deploy ```ts // scripts/deploy.ts, run with `tsx scripts/deploy.ts` import { createPublicClient, createWalletClient, http } from "viem"; import { sepolia } from "viem/chains"; import { deployer } from "../accounts"; import { getOrDeployCounter } from "../deployers"; const transport = http(process.env.RPC_URL); const clients = { walletClient: createWalletClient({ account: deployer, chain: sepolia, transport }), publicClient: createPublicClient({ chain: sepolia, transport }), }; const { contract } = await getOrDeployCounter({ ...clients, args: [0n] }); ``` ## Splitting roles Turnkey policies are per-account, which maps onto the pattern of deploying from one key and owning from another. Create two accounts and pass whichever the step needs: ```ts export const deployer = await createAccount({ client: httpClient, organizationId, signWith: DEPLOYER }); export const admin = await createAccount({ client: httpClient, organizationId, signWith: ADMIN }); ``` Then deploy with `deployer` and hand ownership to `admin` (or better, to a Safe) as the last step of the script. deployoor records who deployed, so the committed JSON shows which key produced the contract. ## Notes * The API private key authorises signing requests, so it is the credential to protect. Turnkey policies can restrict what it is allowed to sign, which is worth setting up before a mainnet run. * Every signature is a network round trip. That is unnoticeable for a deploy, but a script sending hundreds of transactions will feel it. * `@turnkey/viem` supports legacy, EIP-2930, EIP-1559, EIP-4844, and EIP-7702 transaction types, so it is not limited to plain deploys. # Openfort [Openfort](https://www.openfort.io/docs/products/server/viem-integration) backend wallets are EOAs whose keys stay on Openfort's side. The SDK exposes the four signing methods viem wants, so `toAccount` bridges it to a wallet client. ## Install ```bash pnpm add @openfort/openfort-node viem pnpm add -D deployoor tsx ``` ## The account The SDK has no viem helper of its own, so wrap the account with viem's `toAccount`: ```ts // accounts.ts import Openfort from "@openfort/openfort-node"; import { toAccount } from "viem/accounts"; const openfort = new Openfort(process.env.OPENFORT_SECRET_KEY as string, { walletSecret: process.env.OPENFORT_WALLET_SECRET as string, }); const account = await openfort.accounts.evm.backend.get({ id: process.env.OPENFORT_ACCOUNT_ID as string, }); export const deployer = toAccount({ address: account.address, sign: ({ hash }) => account.sign({ hash }), signMessage: ({ message }) => account.signMessage({ message }), signTransaction: (transaction) => account.signTransaction(transaction), signTypedData: (typedData) => account.signTypedData(typedData), }); ``` Create the wallet once, out of band, and keep its id in the environment: ```ts const created = await openfort.accounts.evm.backend.create(); console.log(created.id, created.address); // acc_..., 0x... ``` `create()` mints a new keypair every call. It takes no name, so it is not a get-or-create: calling it on each run would leave you a new, unfunded deployer every time. Create once and `get({ id })` after that, or pass an `idempotencyKey` if you would rather keep it in the deploy path. `get({ address })` works too if you track the address instead. ## The deploy ```ts // scripts/deploy.ts, run with `tsx scripts/deploy.ts` import { createPublicClient, createWalletClient, http } from "viem"; import { baseSepolia } from "viem/chains"; import { deployer } from "../accounts"; import { getOrDeployCounter } from "../deployers"; const transport = http(process.env.RPC_URL); const clients = { walletClient: createWalletClient({ account: deployer, chain: baseSepolia, transport }), publicClient: createPublicClient({ chain: baseSepolia, transport }), }; const { contract, freshDeploy } = await getOrDeployCounter({ ...clients, args: [0n] }); if (freshDeploy) await contract.write.inc(); ``` `toAccount` gives you a local account, so viem asks Openfort for a signature and then broadcasts the raw transaction itself. The wallet's own private key never enters your process, though the credentials below do. ## Multi-chain with one account The account is not bound to a chain, so build a client per chain and loop: ```ts import { arbitrum, base, optimism } from "viem/chains"; const rpcUrls: Record = { [base.id]: process.env.BASE_RPC_URL, [optimism.id]: process.env.OP_RPC_URL, [arbitrum.id]: process.env.ARBITRUM_RPC_URL, }; for (const chain of [base, optimism, arbitrum]) { const rpcUrl = rpcUrls[chain.id]; // http() with no url silently falls back to the chain's public RPC. if (rpcUrl === undefined) throw new Error(`No RPC URL for chain ${chain.id}`); const transport = http(rpcUrl); await getOrDeployCounter({ walletClient: createWalletClient({ account: deployer, chain, transport }), publicClient: createPublicClient({ chain, transport }), args: [0n], }); } ``` Each deploy records under its own `deployments/-/`. ## Notes * The backend wallet pays its own gas, so fund it before the first deploy. * Openfort's gasless path is ERC-4337: it routes a user operation through a paymaster and a smart account, which is not a viem wallet client and so will not drive a deployer. Keep deploys on the funded-EOA path above. * `OPENFORT_SECRET_KEY` authenticates the API and `OPENFORT_WALLET_SECRET` authorises signing. Both are server-only: keep them out of browser bundles and out of source control. The wallet secret is the one that can move funds, so a leak of it is the worst case, but the secret key authenticates every call and deserves the same handling. * viem calls `signTransaction(tx, { serializer })`, and the SDK's method takes only the transaction. A custom serializer is therefore ignored, which does not affect an ordinary deploy. # Deploy scripts Generated deployers are plain async functions. Pass viem clients and constructor args β€” nothing else is required. ## Basic deploy ```ts import { getOrDeployToken } from "../deployers"; const { contract: token } = await getOrDeployToken({ walletClient, publicClient, args: [owner], }); ``` `walletClient` is any viem `WalletClient` β€” local key, injected wallet, Ledger, Privy, Turnkey, etc. deployoor only sees viem clients. ## Return value ```ts const { contract, freshDeploy, receipt, deployment } = await getOrDeployToken({ ... }); ``` | Field | Description | | ------------- | ------------------------------------------------------------ | | `contract` | Typed viem contract β€” `contract.read.*` / `contract.write.*` | | `freshDeploy` | `true` only when this call broadcast a deploy transaction | | `receipt` | Deploy receipt (present only on fresh deploy) | | `deployment` | Full `DeploymentRecord` written to disk | Gate one-time setup on `freshDeploy`: ```ts if (freshDeploy) await token.write.initialize([owner]); ``` ## Redeployment By default (`on-change`), re-running redeploys only when the [deploy identity](/concepts/idempotency) changed β€” runtime bytecode, constructor args, or linked libraries. Override per call: ```ts await getOrDeployToken({ ...clients, args: [owner], redeploymentStrategy: "always" }); // or "never" ``` Set a project default β€” globally or [per chain](/guides/configuration#options) β€” in `deployoor.config`. ## Multiple instances Pass `deploymentName` to track several deployments of the same contract: ```ts await getOrDeployVault({ ...clients, args: [usdc], deploymentName: "Vault_USDC" }); await getOrDeployVault({ ...clients, args: [dai], deploymentName: "Vault_DAI" }); ``` Defaults to the contract name. ## Register external contracts Record a contract you did not deploy (e.g. USDC) with no transaction: ```ts import { register } from "../deployers"; const { contract: usdc } = await register({ publicClient, deploymentName: "USDC", address: "0x…", abi: usdcAbi, }); ``` A `publicClient` is enough, and the contract comes back read-only (`contract.read.*`). Pass a `walletClient` with an account and a chain to get `contract.write.*` as well, and to record that account as the registrant. One with neither bound still gets `write`, but every call has to pass `{ account, chain }`, and the record's `deployer` stays the zero address. `register` will not overwrite a real deployment at the same name β€” `reset` first or use a different `deploymentName`. ## Reset records Forget local records so the next `getOrDeploy` redeploys. Needs only a `publicClient`: ```ts import { reset } from "../deployers"; await reset({ publicClient, deploymentName: "Token" }); // omit deploymentName to forget all records on this chain ``` ## Library-linked contracts For contracts with solc library placeholders, pass the library address map at deploy time: ```ts await getOrDeployMyContract({ ...clients, args: [...], libraries: { MyLib: "0x…" }, }); ``` The library map is stored in the deployment record. ## Multi-chain scripts Pass a different viem client pair per network in the same file. deployoor writes one record folder per chain. ```ts import { baseSepolia, sepolia } from "viem/chains"; import { getOrDeployPing, getOrDeployPong } from "../deployers"; const { contract: ping } = await getOrDeployPing({ ...sepoliaClients, args: [lzEndpoint] }); const { contract: pong } = await getOrDeployPong({ ...baseClients, args: [lzEndpoint] }); await ping.write.setPeer([baseSepoliaEid, peerBytes32(pong.address)]); await pong.write.setPeer([sepoliaEid, peerBytes32(ping.address)]); ``` See the [multi-chain quickstart](/getting-started/quickstart#multi-chain-deploy-layerzero-ping--pong) and [`examples/multi-chain`](https://github.com/raycashxyz/deployoor/tree/main/examples/multi-chain) for a full LayerZero Ping/Pong deploy on Sepolia + Base Sepolia. # Configuration Config lives in `deployoor.config.ts` at your project root. Every field is optional β€” and so is the file itself: `deployoor generate` works with no config at all, using the defaults below. Add one (by hand or with `npx deployoor init`) when you want to change something. ```ts import { defineConfig } from "deployoor"; import { etherscan } from "@deployoor/etherscan"; import { slack } from "@deployoor/slack"; export default defineConfig({ include: ["Token", "Vault"], // default: every contract with bytecode out: "./deployers", // generated deployers output deploymentsPath: "./deployments", // deployment records plugins: [etherscan({ apiKey: process.env.ETHERSCAN_KEY }), slack({ webhook: process.env.SLACK_WEBHOOK })], onPluginError: "warn", // "warn" (default) or "throw" redeploymentStrategy: "on-change", // "on-change" (default) | "never" | "always" redeploymentStrategyByChainId: { 1: "never" }, // pin mainnet to reuse-only }); ``` ## Options | Option | Default | Description | | ------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------- | | `include` | all deployable contracts | Filter by contract name | | `out` | `./deployers` | Where typed deployers are written | | `deploymentsPath` | `./deployments` | Where JSON records are read/written | | `artifactsPath` | framework default | Compiled artifacts dir, if not `./artifacts` (Hardhat) or `./out` (Foundry) | | `framework` | auto-detected | `"hardhat"` | `"foundry"` β€” name the toolchain outright | | `importExtension` | `"auto"` | `.js` on generated relative imports under node16/nodenext ([details](#import-extensions)) | | `plugins` | `[]` | Deploy-lifecycle hooks | | `onPluginError` | `"warn"` | `"throw"` fails the run on plugin error | | `redeploymentStrategy` | `"on-change"` | Reuse vs redeploy when a record exists ([details](/concepts/idempotency)) | | `redeploymentStrategyByChainId` | `{}` | Per-chain override, keyed by chain id | `defineConfig` is typed via `import { defineConfig } from "deployoor"`. If a name in `include` matches nothing after compile, `deployoor generate` warns (typo or missing artifact). ### A non-default project layout Usually nothing to do. deployoor reads `paths.artifacts` from your `hardhat.config.*` and `out` from the active `foundry.toml` profile, so moving your build output needs no deployoor config. `artifactsPath` is for the case neither config states: an output directory produced some other way. It wins over both. ```ts export default defineConfig({ artifactsPath: "./build/artifacts", }); ``` The split is worth internalising, because it decides whether you need a config file at all: | Path | Owner | Who tells deployoor | | ------------------------- | -------------- | ------------------------------ | | sources, artifacts, cache | your framework | nobody β€” it reads their config | | `out`, `deploymentsPath` | deployoor | you, here | [`examples/custom-paths`](https://github.com/raycashxyz/deployoor/tree/main/examples/custom-paths) moves all five and sets only the two deployoor owns. The other examples in that repo have **no config file at all**, because their paths are the defaults. :::warning deployoor reads `paths.artifacts` by **importing** `hardhat.config.*`, and a config that registers a plugin cannot be imported outside a Hardhat run β€” it throws `HH5: HardhatContext is not created`. That covers most real Hardhat projects, so if yours registers anything **and** moves its artifacts directory, set `artifactsPath` here. The symptom is confusing on its own: `deployoor generate` works, because [`@deployoor/hardhat`](/guides/hardhat) hands the resolved path over directly β€” but a deploy or a test reads the config itself, so it looks in the default `./artifacts` and reports nothing compiled. ::: Toolchain detection keys on the config file (`foundry.toml`, `hardhat.config.*`). A project with neither, or one whose sources sit somewhere unusual, can name it outright with `framework`. ## Generated output `deployoor generate` writes, per contract: * `getOrDeploy` β€” idempotent deployer returning a typed viem contract object * Typed artifact module under `deployers/types/` * Project-level `register` and `reset` bound to your config ### Import extensions The relative imports inside `deployers/` are written to match your TypeScript setup, so the generated tree typechecks without you touching `tsconfig.json`. Under `moduleResolution: "node16"` or `"nodenext"` β€” Hardhat 3's default β€” TypeScript rejects an extensionless relative import (`TS2835`), so deployoor emits `./types/Counter.js`. Everywhere else it emits `./types/Counter`, because that is the idiomatic form and a `.js` specifier is a resolution failure in setups that do not map it back to `.ts` β€” webpack without `resolve.extensionAlias`, ts-jest without a `moduleNameMapper`. `moduleResolution` does not have to be spelled out. TypeScript infers it from `module`, and `"node16"`, `"node18"` and `"node20"` all imply `moduleResolution: "node16"` (`"nodenext"` implies `"nodenext"`), so naming any of those and nothing else is enough to require extensions. `"esnext"`, `"preserve"` and `"commonjs"` imply modes that accept extensionless imports. Detection uses [`get-tsconfig`](https://github.com/privatenumber/get-tsconfig) β€” the same resolver `tsx` uses β€” so it reads your project the way your runtime does: the nearest `tsconfig.json` at or above the project, with `extends` resolved (a `moduleResolution` inherited from a preset such as `@tsconfig/node22` counts). A project with no readable tsconfig keeps the extensionless form. Override it when you need to: ```ts export default defineConfig({ importExtension: "none", // "auto" (default) | "none" | "js" }); ``` ## Per-deploy plugin overrides Skip or customize plugins for a single deploy: ```ts await getOrDeployVault({ ...clients, args: [token.address], plugins: { etherscan: false }, }); ``` In tests, disable verifiers and notifiers: ```ts await getOrDeployToken({ ...clients, args: [owner], plugins: { slack: false, etherscan: false }, }); ``` ## In-memory store (tests) Pass a store override to keep deploys off disk: ```ts import { memoryStore } from "deployoor"; await getOrDeployToken({ ...clients, args: [owner], store: memoryStore(), }); ``` `@deployoor/testing`'s `createTestClients()` passes an in-memory store automatically when you spread `clients`. # Testing To make testing feel like writing TypeScript scripts β€” not standing up infrastructure β€” we built [`@deployoor/testing`](/packages) on top of [EDR](https://github.com/NomicFoundation/edr), the Rust EVM behind Hardhat 3. `createTestClients()` gives you an **in-memory EVM** and viem clients wired to it. Your generated `getOrDeploy` functions work unchanged: spread `clients`, deploy, call `contract.read` / `contract.write`. No local node, no `deployments/` writes on disk. ## Quick example ```bash pnpm add -D @deployoor/testing vitest ``` Put your project's compile in front of the test script. A generated deployer holds a name, a fully-qualified name and an abi, and reads bytecode from your artifacts when you call it, so a test run against an uncompiled tree fails β€” clearly, but it fails: ```json { "scripts": { "test": "hardhat compile && vitest run" } } ``` That is the Hardhat form. Foundry projects use `forge build && vitest run`. ```ts import { test, expect } from "vitest"; import { createTestClients } from "@deployoor/testing"; import { getOrDeployToken } from "../deployers"; test("transfer moves the balance", async () => { const clients = await createTestClients(); const [deployer, bob] = clients.accounts; const { contract: token } = await getOrDeployToken({ ...clients, args: [deployer.address], }); await token.write.transfer([bob.address, 1000n]); expect(await token.read.balanceOf([bob.address])).toBe(1000n); }); ``` ## Parallel tests, full hardware Each `createTestClients()` call gets its own isolated in-memory chain. Spin up as many instances as you want β€” run test files or cases **in parallel** and use all your CPU cores without port conflicts or shared chain state. ```ts // vitest.config.ts β€” run files in parallel; each test file can call createTestClients() export default defineConfig({ test: { pool: "threads" }, }); ``` No anvil. No `--fork-url`. No serial "one chain per machine" bottleneck. ## Multiple signers ```ts const clients = await createTestClients(); const alice = clients.walletClientFor(clients.accounts[1]); ``` ## Disable plugins in tests If your config has verifier or notifier plugins: ```ts const clients = await createTestClients(); const [owner] = clients.accounts; await getOrDeployToken({ ...clients, args: [owner.address], plugins: { etherscan: false, slack: false }, }); ``` ## Forking ```ts const clients = await createTestClients({ fork: { url: process.env.MAINNET_RPC, blockNumber: 21_000_000n }, }); ``` Your prefunded accounts stay funded, and real remote state is readable through the same viem clients. Add `deployments` + `deploymentNetwork` to seed committed records onto the fork so `getOrDeploy` reuses production addresses instead of redeploying. ## Requirements `@deployoor/testing` requires **Node β‰₯ 22** (EDR dependency). Your contracts must be **compiled**, because `createTestClients()` replaces the chain and the store, not the artifacts. For Hardhat and Foundry that means running `hardhat compile` or `forge build` first, and test deploys then read bytecode, compiler version and sources from `artifacts/` (or `out/`) exactly as a real deploy does. A test that builds a `TypedArtifact` by hand is the one exception: deployoor uses that artifact exactly as supplied and never reads the filesystem for it. # Consume in your app After you deploy, your `deployments/` folder is the source of truth. The easiest way to consume it in a frontend or backend is [`@wagmi/cli`](https://wagmi.sh/cli) with the [`@deployoor/wagmi`](/packages) plugin β€” typed contract access for **React** and **vanilla JavaScript**, generated from your records. No manual address maps. No copy-pasted ABIs. Run `wagmi generate` whenever deployments change. ## Setup ```bash pnpm add -D @deployoor/wagmi @wagmi/cli ``` ```ts // wagmi.config.ts import { defineConfig } from "@wagmi/cli"; import { deployments } from "@deployoor/wagmi"; import { react } from "@wagmi/cli/plugins"; // or actions() for vanilla JS export default defineConfig({ out: "src/generated.ts", plugins: [ deployments({ path: "./deployments" }), react(), // use actions() instead for non-React apps ], }); ``` ```bash wagmi generate ``` The plugin reads your deployoor records and feeds `@wagmi/cli`. The same deployment name across chains becomes one entry with an `address` map keyed by `chainId`. Mismatched ABIs for the same name fail at generation time β€” fix with distinct `deploymentName` values in your deploy script. ## React Use the `react()` plugin to generate hooks: ```ts // wagmi.config.ts import { react } from "@wagmi/cli/plugins"; export default defineConfig({ out: "src/generated.ts", plugins: [deployments({ path: "./deployments" }), react()], }); ``` ```tsx import { useReadCounter, useWriteCounter } from "./generated"; function Counter() { const { data: number } = useReadCounter(); const { writeContract } = useWriteCounter(); return ( ); } ``` ## Vanilla JavaScript / TypeScript Use the `actions()` plugin for framework-agnostic helpers β€” `readContract`, `writeContract`, `simulateContract`, and friends β€” without React: ```ts // wagmi.config.ts import { actions } from "@wagmi/cli/plugins"; export default defineConfig({ out: "src/generated.ts", plugins: [deployments({ path: "./deployments" }), actions()], }); ``` ```ts import { readCounter, writeCounter } from "./generated"; import { createPublicClient, createWalletClient, http } from "viem"; const publicClient = createPublicClient({ chain, transport: http() }); const walletClient = createWalletClient({ account, chain, transport: http() }); const number = await readCounter(publicClient); await writeCounter(walletClient, { functionName: "increment" }); ``` ## Workflow 1. Deploy with deployoor β†’ `deployments/` updates in your repo. 2. Run `wagmi generate` β†’ `src/generated.ts` reflects the latest addresses and ABIs. 3. Import from generated code in your app β€” React hooks or vanilla actions, same records underneath. Commit both `deployments/` and your generated output (or regenerate in CI before build). # Verify contracts Verification publishes your source so an explorer can show it next to the bytecode. deployoor does it two ways, from the same inputs: * **At deploy time**, through a verifier plugin, in the same run that deployed. * **After the fact**, with `deployoor verify`, from what the deploy recorded. The second one is the reason the first one does not have to succeed. ## What makes after-the-fact verification possible Every deploy pins the exact standard-json input it compiled from: ```text deployments/11155111-sepolia/Counter.json the record β€” address, abi, args, compiler, sourcesHash deployments/sources/0x8f3a….json the standard-json input, content-addressed by that hash ``` An explorer needs precisely that: the sources, the settings, the compiler version, the constructor arguments. All of it is in those two files, so verifying later needs **no artifacts, no recompile, and no matching source tree**. You can verify a contract whose sources have since changed, because the sidecar holds the sources it was actually built from β€” not the ones on disk today. Sidecars are content-addressed, so two deploys of unchanged sources share one file rather than duplicating it. ## Verifiers | Plugin | Key | Notes | | ----------------------- | -------- | ----------------------------------------------------- | | `@deployoor/etherscan` | required | Etherscan V2 β€” one key covers every chain it supports | | `@deployoor/sourcify` | none | Keyless, and not owned by any explorer | | `@deployoor/blockscout` | optional | Per-instance, so you name the instance | | `@deployoor/routescan` | optional | mainnet/testnet derived from the chain id | Add as many as you want β€” they run independently, and one failing does not stop the others. ```ts // deployoor.config.ts import { defineConfig } from "deployoor"; import { etherscan } from "@deployoor/etherscan"; import { sourcify } from "@deployoor/sourcify"; import { blockscout } from "@deployoor/blockscout"; import { routescan } from "@deployoor/routescan"; export default defineConfig({ plugins: [ etherscan({ apiKey: process.env.ETHERSCAN_API_KEY }), sourcify(), blockscout({ instanceUrl: "https://eth-sepolia.blockscout.com" }), routescan(), ], }); ``` ## 1. At deploy time A verifier plugin implements `onContractDeployed`, so it runs when a deploy broadcasts. Nothing else to call β€” your deploy script is unchanged: ```bash tsx --env-file=.env scripts/deploy.ts ``` ```text [deployoor] Deployed Counter on 11155111-sepolia at 0xeb7a…3445 β€” first deployment [etherscan] contracts/Counter.sol:Counter verified ``` By default a plugin failure **warns and the deploy still succeeds** β€” the deploy is the valuable part and it already happened, so a transient explorer outage must not fail the run. Set `onPluginError: "throw"` if you would rather it fail loudly. :::note Deploy-time verification races the explorer's indexer. Submitting immediately after the receipt often gets "Unable to locate ContractCode", because the chain is ahead of the explorer. The verifiers re-submit while that is the answer, bounded by `maxPolls`. In a live Sepolia run this took six retries over about twelve seconds before it went through. ::: ## 2. After the fact ```bash npx deployoor verify ``` This walks the records, reads each one's pinned sources, and submits through every plugin that has an `onVerify` hook. It is the same request the deploy-time path builds. ```text [etherscan] contracts/Ledger.sol:Ledger verified [sourcify] contracts/Ledger.sol:Ledger verified [blockscout] contracts/Ledger.sol:Ledger verified deployoor: checked 1 record(s) through etherscan, sourcify, blockscout verified 11155111-sepolia/Ledger at 0x1a9a…88e7 (etherscan, sourcify, blockscout) deployoor: 1 verified ``` Use it when: * **A deploy-time verification failed** β€” an outage, a rate limit, a missing key at the time. * **You added a verifier later.** Contracts deployed before you configured Sourcify can be verified now. * **The sources moved on.** The pinned sidecar is what gets submitted, not your working tree. * **You never verified at all.** Deploy first, verify when you are ready to publish. ### Narrowing the run ```bash npx deployoor verify --network 11155111-sepolia # one network: chain id, slug, or the full key npx deployoor verify --contract Counter # one deployment name npx deployoor verify --plugin etherscan # one verifier ``` `--network` takes a chain id (`11155111`), a slug (`sepolia`), or the record key (`11155111-sepolia`). A [store](/concepts/deployment-stores) without `listAll` can only look up an exact key, and says so rather than reporting no records. ### Outcomes | Outcome | Meaning | | -------------- | ----------------------------------------------------------------- | | `verified` | At least one plugin accepted it | | `unverifiable` | The record predates pinned sources, so there is nothing to submit | | `FAILED` | A plugin was asked and refused | `unverifiable` is reported, not fatal: those records were written before sources were pinned, and nothing you run now can recover sources that were never saved. Redeploy to get a verifiable record. ## Already verified Every verifier treats "already verified" as success rather than an error, which matters more than it sounds: * Re-running `deployoor verify` is safe and idempotent. * Explorers import from each other. A contract verified on Etherscan can show up already verified on Blockscout minutes later β€” that is a normal first run, not a repeat. ## Troubleshooting **"Missing or unsupported chainid parameter"** β€” you are on a `@deployoor/etherscan` older than 0.4.0. `chainid` has to be a query parameter for Etherscan V2, and earlier versions sent it only in the body. **Routescan times out** β€” its queue is slower than Etherscan's. The default budget is 40 seconds (`maxPolls: 20` Γ— 2 s); raise `maxPolls`. **Blockscout asks for `instanceUrl`** β€” there is no default, because Blockscout is software many chains run their own instance of rather than one service. Deriving a host from the chain id would be wrong for every self-hosted instance, and a wrong instance answers about a different chain. **A constructor-argument mismatch** β€” the record holds the args as deployed and the verifiers re-encode from that, so this points at a contract deployed outside deployoor, or a record edited by hand. # Plugins Plugins are lifecycle hooks. A verifier, a Slack notification, and a gas report are all the same kind of thing β€” small named objects subscribed to hooks in the deploy pipeline, and to `deployoor verify`. ## Built-in plugins | Package | Hooks | Purpose | | ----------------------- | -------------------------------------- | ------------------------------------ | | `@deployoor/etherscan` | `onContractDeployed`, `onVerify` | Etherscan V2 (one key, all chains) | | `@deployoor/sourcify` | `onContractDeployed`, `onVerify` | Sourcify v2 (keyless) | | `@deployoor/blockscout` | `onContractDeployed`, `onVerify` | Any Blockscout instance | | `@deployoor/routescan` | `onContractDeployed`, `onVerify` | Routescan (chain id picks the index) | | `@deployoor/slack` | `onContractDeployed`, `onDeployFailed` | Slack webhook on deploy | The four verifiers are independent β€” run as many as you like, and see [Verify contracts](/guides/verify) for what each needs and how `deployoor verify` replays them. ```ts import { defineConfig } from "deployoor"; import { etherscan } from "@deployoor/etherscan"; import { sourcify } from "@deployoor/sourcify"; import { slack } from "@deployoor/slack"; export default defineConfig({ plugins: [ etherscan({ apiKey: process.env.ETHERSCAN_KEY }), sourcify(), slack({ webhook: process.env.SLACK_WEBHOOK }), ], }); ``` By default a failing plugin **warns** and the deploy still records (the transaction already happened). Set `onPluginError: "throw"` to fail the run. ## Hooks Every hook is plain async (or sync) and receives `(ctx, deps)`, where `deps` carries `fetch`, `now` and `log` β€” inject them in tests instead of reaching for the globals. | Hook | Called by | Context | | -------------------- | ------------------ | ------------------------------------------------------------------- | | `onContractDeployed` | a deploy | `DeployedContext` β€” `deployment`, `reused`, `receipt?`, `metadata?` | | `onDeployFailed` | a failed deploy | `DeployFailedContext` β€” names, chain, `cause` | | `onVerify` | `deployoor verify` | `VerifyContext` β€” `deployment`, `metadata` | ### `onVerify` `deployoor verify` verifies recorded deployments after the fact, and calls **only** this hook. Two consequences worth knowing: * **A plugin that omits `onVerify` is skipped by that command.** This is how `@deployoor/slack` stays quiet on a verify run without inspecting anything. If none of your configured plugins implements it, `deployoor verify` fails rather than reporting a run that did nothing. * **`VerifyContext` is not `DeployedContext`.** Nothing was deployed, so there is no `receipt` and no `reused` flag to misread, and `metadata` is **required** β€” a record whose sources were never pinned cannot be verified from committed data, so it is reported as unverifiable and never reaches a plugin. There is no `options` either: your plugin instance already closes over its own configuration, which is what `etherscan({ apiKey })` is. ```ts export interface VerifyContext { readonly deployment: DeploymentRecord; // address, chain, abi, constructor args, libraries readonly metadata: ContractMetadata; // pinned fqn + compiler version + standard-json } ``` A verifier should implement both hooks over one shared body, so it works at deploy time and after the fact. Both are handed exactly a record plus a `ContractMetadata`, so there is nothing to branch on: ```ts import { definePlugin, type ContractMetadata, type DeploymentRecord, type PluginDeps, } from "deployoor/plugin"; const submit = async (deployment: DeploymentRecord, metadata: ContractMetadata, deps: PluginDeps) => { // …one implementation: build the standard-json request, poll it to a conclusion, throw on failure }; export const myVerifier = () => definePlugin({ name: "my-verifier", onContractDeployed: async (ctx, deps) => { if (ctx.metadata === undefined) return; // a deploy may have no compiler input to offer await submit(ctx.deployment, ctx.metadata, deps); }, onVerify: (ctx, deps) => submit(ctx.deployment, ctx.metadata, deps), }); ``` Throwing from `onVerify` marks that contract failed for that plugin; the run moves to the next contract and exits non-zero at the end. ## Author a plugin Plugins import only from `deployoor/plugin`: ```ts import { definePlugin } from "deployoor/plugin"; export const myPlugin = (opts: { url: string }) => definePlugin({ name: "my-plugin", onContractDeployed: async (ctx, { fetch }) => { await fetch(opts.url, { method: "POST", body: JSON.stringify({ contract: ctx.deployment.contractName, address: ctx.deployment.address, }), }); }, }); ``` Ship it as its own npm package that peer-depends on `deployoor`. ## Hardhat plugin (not a lifecycle plugin) [`@deployoor/hardhat`](/packages) is different β€” it hooks `hardhat compile` to run `deployoor generate` automatically. It imports `deployoor/generate`, not `deployoor/plugin`. # Hardhat deployoor reads Hardhat `artifacts/` β€” **both Hardhat 2 and Hardhat 3** β€” and generates typed deployers from them. ## Compile and generate ```bash npx hardhat compile && npx deployoor generate ``` | Input | Location | | ------------------ | ------------------ | | Compiled artifacts | `artifacts/` | | Config file | `hardhat.config.*` | ## Auto-generate on compile Add `@deployoor/hardhat` to skip the separate `generate` step β€” it regenerates `./deployers/` after every `hardhat compile`. It ships an entry for each Hardhat major. ```bash pnpm add -D @deployoor/hardhat deployoor viem ``` **Hardhat 2** β€” register by side effect: ```js // hardhat.config.js require("@deployoor/hardhat"); module.exports = { solidity: "0.8.24" }; ``` **Hardhat 3** β€” import the `/v3` plugin object and add it to `plugins`: ```ts // hardhat.config.ts import { defineConfig } from "hardhat/config"; import deployoor from "@deployoor/hardhat/v3"; export default defineConfig({ plugins: [deployoor], solidity: "0.8.28" }); ``` Either way: ```bash npx hardhat compile # deployoor: generated 3 deployer file(s) ``` Prefer no plugin? `deployoor generate` reads Hardhat 2 and Hardhat 3 `artifacts/` directly (it resolves each artifact's inline `buildInfoId` to `artifacts/build-info/.json` on Hardhat 3), so `npx hardhat compile && npx deployoor generate` works too. ## Example Runnable examples: [`examples/hardhat`](https://github.com/raycashxyz/deployoor/tree/main/examples/hardhat) (Hardhat 2) and [`examples/hardhat-v3`](https://github.com/raycashxyz/deployoor/tree/main/examples/hardhat-v3) (Hardhat 3) β€” both use the plugin: compile β†’ auto-generate β†’ deploy β†’ committed record. # Foundry deployoor reads Foundry `out/` artifacts and `out/build-info/` for compiler metadata. ## Why use this on a Foundry project Keep `forge build` and `forge test`. Nothing here replaces them, and no Hardhat is involved. What you get on top is a **TypeScript path over the same artifacts**: typed deploy scripts run with `tsx`, integration tests in vitest, and a committed `deployments/` record your app imports. A Solidity test proves the contract behaves; a TypeScript test additionally proves the path your frontend and backend take β€” viem, the ABI, the address β€” works. `forge script` broadcasts don't give you that: they carry no ABI, and the documented way to read an address back out is positional (`.transactions[0].contractAddress`). So the division is Solidity for unit and fuzz tests, TypeScript for deploys and integration. ## Compile and generate ```bash forge build && npx deployoor generate ``` | Input | Location | | ------------------ | ----------------- | | Compiled artifacts | `out/` | | Build info | `out/build-info/` | | Config file | `foundry.toml` | There is no Foundry plugin today β€” run `deployoor generate` after `forge build`, or add it to your `package.json` scripts / CI. ## Example Runnable example: [`examples/foundry`](https://github.com/raycashxyz/deployoor/tree/main/examples/foundry) β€” forge build β†’ generate β†’ in-memory test with `@deployoor/testing`. # Upgrading to 0.7 0.7 changes what `deployoor generate` writes and what you do with it. Two things move: * **Generated deployers no longer inline `standardJsonInput`.** They carry a name, a fully-qualified name and an ABI; everything else is read from your compiled artifacts when a deploy runs. Files that were hundreds of kilobytes are now a few. * **`deployers/` is committed.** It used to be build output you gitignored. It is now small enough to diff and needed for a fresh clone to typecheck, so it belongs in the repo. There is also a new command, [`deployoor verify`](/reference/cli#verify), which verifies a deployment from its record long after the deploy β€” see [verifying later](/concepts/version-control#verifying-later). ## Point an agent at this page The whole upgrade is mechanical except for two judgement calls, both flagged below. If you use a coding agent, this works: ```text Upgrade this project to deployoor 0.7 by following https://deployoor.dev/guides/upgrading. Work through the steps in order. Stop and ask me at the two points marked "ask first". ``` ## 1. Bump the packages `deployoor` 0.7 and, if you use them, the verifier plugins β€” their `onVerify` hook needs the new core, so their peer range requires it. ```bash pnpm add -D deployoor@^0.7.0 pnpm add -D @deployoor/etherscan@^0.3.0 @deployoor/sourcify@^0.3.0 # if you use them ``` `@deployoor/hardhat`, `@deployoor/testing`, `@deployoor/slack` and `@deployoor/wagmi` need no bump β€” nothing they call changed shape. ## 2. Recompile, then regenerate Order matters. `generate` reads compiled artifacts, and from 0.7 so does every deploy β€” so an artifacts directory that is stale or absent is now an error at both points rather than something the old inlined deployers papered over. ```bash npx hardhat compile && npx deployoor generate # or forge build && npx deployoor generate ``` Delete any previously generated tree first if your `out` directory changed. Otherwise `generate` overwrites in place and stale files for contracts you have since deleted stay behind. ## 3. Commit `deployers/` `generate` checks whether git is ignoring its own output and offers to remove the rule: ```text deployoor: generated 3 file(s) deployoor: git is ignoring output that is meant to be committed: .gitignore:4 (`deployers`) ignores deployers/ β€” the generated deployers, which a fresh clone cannot typecheck or deploy without deployoor: remove line 4 of .gitignore now? [y/N] ``` Accepting takes the rule and any deployoor comment introducing it, so you are not left with `# generated by deployoor generate` above nothing. `deploymentsPath` is checked the same way, since a rule ignoring your records is the worse of the two. The question is put to `git check-ignore`, not answered by reading `.gitignore`, so a nested ignore file or a `!deployers/` you already added is accounted for β€” if you overrode the rule yourself, you are not asked again. Two cases report and stop there rather than offering an edit: * **The pattern covers more than deployoor's output** β€” `build` when your `out` is `./build/deployers`. Removing it would un-ignore everything else under `build/`, which is your call, not deployoor's. Add `!build/deployers/` below it, or move `out`. * **The rule lives outside the project** β€” `.git/info/exclude` or a global `core.excludesFile`. Those are clone- or machine-wide, so they are named and left alone. Then exclude the directory from your formatter and linter. An ABI is emitted as one long line, so both will want to rewrite files that regenerating puts straight back β€” see [version control](/concepts/version-control#tell-your-formatter-to-skip-them). :::warning[Ask first] Committing `deployers/` is a repository convention, not a correctness requirement. Everything still works if you keep ignoring them, as long as everyone who clones the repo knows to run `generate` before typechecking. Ask before editing a `.gitignore`. ::: ## 4. Deploys and tests now need compiled artifacts A deployer resolves its bytecode, compiler version and sources from `artifacts/` (or `out/`) at the moment you call it. Nothing is inlined, so nothing can be deployed from a tree that has not been compiled. The failure names the toolchain it found and the command to run: ```text No compiled artifacts in /repo/artifacts. This is a Hardhat project (found hardhat.config.ts), so deployoor looked in the default output directory. Either: 1. Nothing compiled yet β€” run `npx hardhat compile`. 2. The output lives elsewhere β€” deployoor reads paths.artifacts in hardhat.config when it can, so if that is set and this path is still wrong, name it directly: export default defineConfig({ artifactsPath: "./build/artifacts" }) ``` If your test script ran `vitest` directly, it now needs a compile in front of it: ```json { "scripts": { "test": "hardhat compile && vitest run" } } ``` Tests that build a `TypedArtifact` by hand, and `@deployoor/testing`, are unaffected β€” a full artifact is used as given and never sends deployoor to disk. ## 5. A changed ABI is now an error, not a silent mismatch The generated ABI is compared against the compiled one on every deploy. Drift means the deployers were generated from an older compile, so the run stops and names what moved: ```text contracts/Counter.sol:Counter's abi no longer matches its compiled artifact. Re-run `deployoor generate`, then deploy again. - function gone() view ``` This is the failure that previously did not exist: the old inlined ABI would have encoded your constructor arguments against an interface the deployed contract does not have, and written that ABI into the record. Regenerating is the whole fix. Reordered entries are not drift β€” the comparison ignores entry order, key order, and `internalType`, all of which solc varies freely. ## 6. Deployments recorded before 0.7 Your existing records in `deployments/` are read unchanged: no field changed shape, and the pinned sources under `deployments/sources/` keep resolving because the sidecar format and the way its hash is computed are both untouched. One case needs your attention. A record can only be verified after the fact if it has a `sourcesHash` and the blob it points at is still in the repo: ```bash # records with no pinned sources (use your deploymentsPath if you changed it) grep -LE '"sourcesHash"[[:space:]]*:[[:space:]]*"0x' deployments/*/*.json \ | grep -vE '^deployments/sources/|\.sources\.json$' ``` Both halves earn their keep. The pattern matches the **field**, not the word. A record whose ABI happens to contain an entry named `sourcesHash` would satisfy a bare `grep -L sourcesHash` and drop out of the list while still having no pinned sources β€” a silent omission from the one list where that matters. The second `grep` drops two kinds of file that live under `deployments/` without being records: the pinned blobs in `deployments/sources/`, and the per-deployment `.sources.json` sidecars older projects have from before pinning was content-addressed. Neither carries a `sourcesHash` of its own, so without it every one is listed as a deployment needing attention β€” and this being an upgrade guide, those are exactly the projects that have them. `fsStore` skips both when it reads records; this makes the one-liner agree with it. Anything listed there cannot be verified by `deployoor verify` at all β€” not "only while the source tree matches". The fully-qualified contract name and the standard-json input live solely in the sidecar, so with no `sourcesHash` there is nothing to submit, and the record is reported `unverifiable` before any plugin runs. A matching working tree does not help, because nothing reads it. Two ways forward, neither free: * **Verify it with your explorer's own tooling**, against the source tree at the commit it was deployed from. That is the only route that verifies *that* address. * **Redeploy.** The new deploy pins its sources and is verifiable from then on β€” but it is a new contract at a new address, recorded alongside the old one. It does not make the historic deployment verifiable. :::warning[Ask first] Deciding what to do about unverifiable historic records depends on which of those contracts anyone actually needs verified. Ask rather than guessing. ::: ## 7. If you wrote a plugin `VerifyContext` is new and `onVerify` is optional, so an existing plugin keeps working with no change. If you add `onVerify`, note that its context carries `deployment` and `metadata` and nothing else β€” there is no `options`, because a plugin instance already closes over its own options. See [plugins](/guides/plugins#onverify) for the hook and what it receives. ## Nothing else changed `getOrDeploy*`, `register`, `reset`, the shape of a deployment record, `deployoor.config.ts`, the store adapters, and the `@wagmi/cli` bridge are all as they were. # Migrate from Hardhat scripts Coming from plain `hardhat run` deploy scripts, with no deploy plugin. Keep Hardhat for what it is good at, compiling and running a node, and move the deploy step out of the runtime. The [comparison](/comparison/hardhat) covers what differs job by job. This is the mechanical path. ## What you have today Nothing on disk records what you deployed. The addresses live wherever your team put them: an `.env`, a spreadsheet, a pinned Slack message, a `constants.ts` in the frontend. That is the thing migration fixes, and it is also the only fiddly part, because deployoor cannot import records that were never written. ## Step 1: Install alongside Nothing about your Hardhat setup changes yet. ```bash pnpm add -D deployoor npx hardhat compile npx deployoor generate ``` `generate` reads `artifacts/` and writes one typed function per contract into `./deployers`. Your existing scripts keep working. If you would rather not run `generate` by hand, [`@deployoor/hardhat`](/guides/hardhat) hooks it onto `hardhat compile`. ## Step 2: Import the addresses you already have Plain Hardhat left you no file to read, so list the addresses once, from wherever they currently live. ```ts // scripts/import.ts, run with `RPC_URL=https://… tsx scripts/import.ts` import { readFileSync } from "node:fs"; import { createPublicClient, http, type Abi } from "viem"; import { sepolia } from "viem/chains"; import { register } from "../deployers"; const chain = sepolia; // the viem chain these addresses are on // Whatever you have been passing around by hand. const contracts = [ { deploymentName: "Counter", address: "0x...", artifact: "artifacts/contracts/Counter.sol/Counter.json" }, { deploymentName: "Token", address: "0x...", artifact: "artifacts/contracts/Token.sol/Token.json" }, ] as const; const publicClient = createPublicClient({ chain, transport: http(process.env.RPC_URL) }); // Filing one chain's addresses under another silently corrupts the records, so check // the RPC really is the chain named above before writing anything. const liveChainId = await publicClient.getChainId(); if (liveChainId !== chain.id) throw new Error(`RPC is chain ${liveChainId}, expected ${chain.id}`); for (const { deploymentName, address, artifact } of contracts) { const { abi } = JSON.parse(readFileSync(artifact, "utf8")) as { abi: Abi }; await register({ publicClient, deploymentName, address, abi }); } ``` Check each address against a block explorer first. There is no sidecar here to cross-check against, so a wrong address becomes a wrong record with nothing to catch it. Commit the `deployments/` folder it writes. If you have nothing worth keeping, skip this step. Deploy fresh and let the records accumulate. :::warning `register` records a contract deployoor did not deploy, with no transaction and no gas. Imported records have `kind: "external"`, carry no compiler metadata, and run no plugins, so nothing tries to verify them. `register` will not overwrite a record from a real deployoor deploy: on a collision, [`reset`](/concepts/idempotency) first, or import under a different name. ::: :::info A packaged migrator is planned, which will reduce this step to a single command. Until then, the script above is the path. ::: ## Step 3: Port a deploy script The old script, which needed the runtime and printed an address into the void: ```ts // scripts/deploy.ts, run with `hardhat run scripts/deploy.ts --network sepolia` import hre from "hardhat"; const counter = await hre.viem.deployContract("Counter", [0n]); console.log("Counter:", counter.address); ``` The new one, which is an ordinary Node program: ```ts // scripts/deploy.ts, run with `tsx scripts/deploy.ts` import { sepolia } from "viem/chains"; import { clientsFor } from "../clients"; // your viem clients import { getOrDeployCounter } from "../deployers"; const { contract, freshDeploy } = await getOrDeployCounter({ ...clientsFor(sepolia), args: [0n], }); if (freshDeploy) await contract.write.increment(); ``` `accounts.ts` builds the signer once and `clients.ts` turns a chain into the two clients. Both are set up in the [quickstart](/getting-started/quickstart), and every script, test and ops task imports them from there. Moving to a hosted or hardware wallet later is an edit to `accounts.ts` alone, so see the [recipes](/recipes). Two other things changed. There is no `--network`, because the chain is an argument. And the result is recorded, so a second run returns the existing contract instead of deploying another. ## Step 4: Point the frontend at the records If your app imports a hand-maintained address map, replace it with generated bindings: ```ts // wagmi.config.ts import { defineConfig } from "@wagmi/cli"; import { actions } from "@wagmi/cli/plugins"; import { deployments } from "@deployoor/wagmi"; export default defineConfig({ out: "src/generated.ts", plugins: [deployments({ path: "./deployments" }), actions()], // or react() for hooks }); ``` ```bash npx wagmi generate ``` See [consumption](/guides/consumption). ## Step 5: Move the tests Hardhat tests keep working, and you can leave them. The reason to move one is that a deployoor test calls the same function your deploy script calls, so the deploy path gets covered: ```ts import { describe, it, expect } from "vitest"; import { createTestClients } from "@deployoor/testing"; import { getOrDeployCounter } from "../deployers"; describe("Counter", () => { it("increments", async () => { const clients = await createTestClients(); // in-memory EVM plus an ephemeral store const { contract } = await getOrDeployCounter({ ...clients, args: [0n] }); await contract.write.increment(); expect(await contract.read.number()).toBe(1n); }); }); ``` Test deploys use an ephemeral [store](/concepts/deployment-stores), so they never touch your committed records. See [testing](/guides/testing). ## What you keep Hardhat stays. It compiles your Solidity, runs the local node with its dev controls, and forks live chains. `hardhat compile` remains the step that feeds `deployoor generate`. What leaves is the deploy step, the `--network` flag, and the config stanza that decided which chains and which signers your scripts were allowed to use. # Migrate from Hardhat Ignition Ignition already records what it deployed, so this migration is mostly a file conversion. Its records become deployoor records, and its modules become plain scripts. The [comparison](/comparison/hardhat-ignition) covers what differs. In short: move if you want deploys that are ordinary functions, callable from tests and ops scripts and typed end to end. Stay if you rely on Ignition's execution journal to resume a partially failed deployment, because deployoor has no equivalent. ## What maps to what | Ignition | deployoor | | --------------------------------------------------------- | ------------------------------------------------ | | `ignition/modules/Counter.ts` returning futures | `scripts/deploy.ts` calling `getOrDeployCounter` | | `m.contract("Counter", [0n])` | `await getOrDeployCounter({ ...clients, args })` | | `ignition/deployments//deployed_addresses.json` | `deployments/-/Counter.json` | | `ignition/deployments//artifacts/Module#Counter.json` | the `abi` inside that same record | | `--network sepolia` | the chain on your wallet client | | `journal.jsonl` for resuming a failed run | no equivalent | Ignition splits the address and the ABI across two files. deployoor puts both in one record, which is what makes the record enough on its own for the frontend. ## Step 1: Install alongside ```bash pnpm add -D deployoor npx hardhat compile npx deployoor generate ``` Your Ignition modules keep working. Nothing is removed yet. ## Step 2: Import your live addresses Ignition splits the address and the ABI across two files, so the import reads both and writes one record. ```ts // scripts/import.ts, run with `RPC_URL=https://… tsx scripts/import.ts` import { readFileSync } from "node:fs"; import { join } from "node:path"; import { createPublicClient, http, type Abi, type Address } from "viem"; import { sepolia } from "viem/chains"; import { register } from "../deployers"; const dir = "ignition/deployments/chain-11155111"; // the deployment id folder const chain = sepolia; // the viem chain it corresponds to const publicClient = createPublicClient({ chain, transport: http(process.env.RPC_URL) }); // Filing one chain's addresses under another silently corrupts the records, so check // the RPC really is the chain named above before writing anything. const liveChainId = await publicClient.getChainId(); if (liveChainId !== chain.id) throw new Error(`RPC is chain ${liveChainId}, expected ${chain.id}`); // { "CounterModule#Counter": "0x..." } const addresses = JSON.parse(readFileSync(join(dir, "deployed_addresses.json"), "utf8")) as Record< string, Address >; await Promise.all( Object.entries(addresses).map(([futureId, address]) => { const { abi } = JSON.parse(readFileSync(join(dir, "artifacts", `${futureId}.json`), "utf8")) as { abi: Abi; }; // "CounterModule#Counter" is not a filename: take the contract name after the '#'. const deploymentName = futureId.split("#").at(-1) as string; return register({ publicClient, deploymentName, address, abi }); }), ); ``` Run it once per deployment folder. If one module deploys the same contract twice, the part after `#` collides, so give those distinct names rather than letting the second overwrite the first. Commit the `deployments/` folder it writes. :::warning `register` records a contract deployoor did not deploy, with no transaction and no gas. Imported records have `kind: "external"`, carry no compiler metadata, and run no plugins, so nothing tries to verify them. `register` will not overwrite a record from a real deployoor deploy: on a collision, [`reset`](/concepts/idempotency) first, or import under a different name. ::: :::info A packaged migrator is planned, which will reduce this step to a single command. Until then, the script above is the path. ::: ## Step 3: Port a module An Ignition module describes futures and hands them to the CLI: ```ts // ignition/modules/Counter.ts import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; export default buildModule("CounterModule", (m) => { const counter = m.contract("Counter", [0n]); m.call(counter, "increment", []); return { counter }; }); ``` ```bash npx hardhat ignition deploy ignition/modules/Counter.ts --network sepolia ``` The same deployment as a script, where the futures are just values: ```ts // scripts/deploy.ts, run with `tsx scripts/deploy.ts` import { sepolia } from "viem/chains"; import { clientsFor } from "../clients"; // your viem clients import { getOrDeployCounter } from "../deployers"; const { contract, freshDeploy } = await getOrDeployCounter({ ...clientsFor(sepolia), args: [0n], }); // m.call becomes a normal call, guarded so it only runs on a fresh deploy if (freshDeploy) await contract.write.increment(); ``` `accounts.ts` builds the signer once and `clients.ts` turns a chain into the two clients. Both are set up in the [quickstart](/getting-started/quickstart), and every script, test and ops task imports them from there. Moving to a hosted or hardware wallet later is an edit to `accounts.ts` alone, so see the [recipes](/recipes). `m.contract` returns a future you cannot read from, so ordering is expressed through the module graph. `getOrDeployCounter` returns the contract, so ordering is just the order of your statements, and a value from one deploy can be an argument to the next: ```ts const clients = clientsFor(sepolia); const { contract: token } = await getOrDeployToken({ ...clients, args: [1000n] }); const { contract: vault } = await getOrDeployVault({ ...clients, args: [token.address] }); ``` Constructor args are typed against the ABI here, so a wrong arity or a `number` where the contract wants a `uint256` is a compile error rather than a runtime one. ## Step 4: Point the frontend at the records Ignition leaves the app to read `deployed_addresses.json` and find the ABI separately. The deployoor record has both, so `@wagmi/cli` can generate from it: ```ts // wagmi.config.ts import { defineConfig } from "@wagmi/cli"; import { actions } from "@wagmi/cli/plugins"; import { deployments } from "@deployoor/wagmi"; export default defineConfig({ out: "src/generated.ts", plugins: [deployments({ path: "./deployments" }), actions()], // or react() for hooks }); ``` ```bash npx wagmi generate ``` See [consumption](/guides/consumption). ## What will not carry over * **The execution journal.** Ignition records every operation before it runs, so an interrupted deployment can resume mid-flight. deployoor's idempotency is per contract: a completed deploy is recorded and skipped next run, but a run that dies between two contracts simply redeploys nothing and continues from the records it has. For a long sequence on an expensive chain, that difference is real. * **Module composition.** `m.useModule` has no direct equivalent. In a script, importing a function and calling it is the equivalent, which is less structured and also less to learn. * **`--reset` and deployment ids.** deployoor keys records by chain id, not by a deployment id you choose, so parallel named deployments of the same contract to one chain need distinct `deploymentName`s. # Migrate from hardhat-deploy Move to deployoor without redeploying anything. The path is: install alongside your existing setup, import your live addresses with `register`, port one deploy script, then delete the old one when you're ready. The [comparison](/comparison) covers what differs. In short: migrate if you want **one tool across Hardhat and Foundry**, deployers that take viem clients and return typed contracts, accounts owned by your own viem module, and a flat-JSON record keyed by chainId. Stay where you are if you depend on proxies, diamonds, deterministic addresses, or bytecode-diff redeploys, hardhat-deploy has those and deployoor does not yet. :::info This guide covers **hardhat-deploy v1** (`deployments//.json` + `.chainId`, ethers v5, Hardhat 2) and **v2 / rocketh** (same record shape plus a `.chain` sidecar, viem, Hardhat 3). The record format barely changed between them, so the import works for both. ::: ## What maps to what Both tools already agree on the important thing: one committed JSON file per contract, with the address **and** the ABI in it. | hardhat-deploy | deployoor | Note | | -------------------------------- | ---------------------------------------------- | ------------------------------------------------------- | | `deployments//.json` | `deployments/-/.json` | chainId moves into the path | | `.chainId` (v1) / `.chain` (v2) | , (in the path and in every record) | no sidecar needed | | `address` | `address` | same | | `abi` | `abi` | same | | `bytecode`, `deployedBytecode` | `bytecode` | deployoor stores creation bytecode | | `args` (v1) / `argsData` (v2) | `constructorArgs` | decoded values; `bigint` is written as a string | | `transaction.hash` | `transactionHash` | same value | | `metadata` | `compiler.version` + `compiler.settings` | deployoor splits the fields verification needs out | | `numDeployments` | , | not tracked | | `libraries` | `libraries` | same | | `history` (v1) | , | deployoor keeps one record per (chain, deployment name) | | `deploy(...)` in a tagged script | `getOrDeploy({ ... })` in a plain script | see [step 3](#step-3-port-a-deploy-script) | | `hre.deployments.get("X")` | read the JSON, or `@deployoor/wagmi` | see [consumption](/guides/consumption) | ## Step 1: Install alongside Nothing here touches your hardhat-deploy setup. ```bash pnpm add -D deployoor viem tsx npx deployoor init # writes deployoor.config.ts npx hardhat compile npx deployoor generate # emits ./deployers ``` You now have a typed `getOrDeploy` per contract. Your hardhat-deploy scripts still work. ## Step 2: Import your live addresses Point `register` at your existing hardhat-deploy folder and every live address becomes a deployoor record. ```ts // scripts/import.ts, run with `RPC_URL=https://… tsx scripts/import.ts` import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { createPublicClient, http, type Abi, type Address } from "viem"; import { sepolia } from "viem/chains"; import { register } from "../deployers"; const dir = "deployments/sepolia"; // hardhat-deploy's folder for this network const chain = sepolia; // the viem chain it corresponds to const publicClient = createPublicClient({ chain, transport: http(process.env.RPC_URL) }); // Filing one chain's addresses under another silently corrupts the records, so check // the RPC really is the chain named above before writing anything. const liveChainId = await publicClient.getChainId(); if (liveChainId !== chain.id) throw new Error(`RPC is chain ${liveChainId}, expected ${chain.id}`); const files = readdirSync(dir).filter((f) => f.endsWith(".json")); await Promise.all( files.map((file) => { const { address, abi } = JSON.parse(readFileSync(join(dir, file), "utf8")) as { address: Address; abi: Abi; }; return register({ publicClient, deploymentName: file.replace(/\.json$/, ""), address, abi }); }), ); ``` Run it once per network, changing `dir` and `chain` together. This works for both v1 and v2 folders, since the record shape barely changed. Commit the `deployments/` folder it writes: that is now your source of truth. :::warning `register` records a contract deployoor did not deploy, with no transaction and no gas. Imported records have `kind: "external"`, carry no compiler metadata, and run no plugins, so nothing tries to verify them. `register` will not overwrite a record from a real deployoor deploy: on a collision, [`reset`](/concepts/idempotency) first, or import under a different name. ::: :::info A packaged migrator is planned, which will reduce this step to a single command. Until then, the script above is the path. ::: ## Step 3: Port a deploy script hardhat-deploy scripts are tagged modules that receive an environment. deployoor scripts are plain Node programs that build their own viem clients. **Before** (hardhat-deploy v1): ```ts // deploy/001_token.ts import type { HardhatRuntimeEnvironment } from "hardhat/types"; const func = async (hre: HardhatRuntimeEnvironment) => { const { deployer } = await hre.getNamedAccounts(); const token = await hre.deployments.deploy("Token", { from: deployer, args: [deployer], log: true, skipIfAlreadyDeployed: true, }); await hre.deployments.deploy("Vault", { from: deployer, args: [token.address], log: true }); }; func.tags = ["Token"]; export default func; ``` **After** (deployoor): ```ts // scripts/deploy.ts, run it with `tsx scripts/deploy.ts`, no Hardhat runtime import { sepolia } from "viem/chains"; import { deployer } from "../accounts"; // your signer import { clientsFor } from "../clients"; // your viem clients import { getOrDeployToken, getOrDeployVault } from "../deployers"; const clients = clientsFor(sepolia); const { contract: token } = await getOrDeployToken({ ...clients, args: [deployer.address] }); const { contract: vault, freshDeploy: vaultIsNew } = await getOrDeployVault({ ...clients, args: [token.address], }); // `freshDeploy` is per call, so gate setup on the deployment it belongs to, reading Token's flag // here would skip initializing a brand-new Vault whenever Token was already recorded. if (vaultIsNew) await vault.write.initialize([token.address]); console.log(token.address, vault.address); ``` `accounts.ts` builds the signer once and `clients.ts` turns a chain into the two clients. Both are set up in the [quickstart](/getting-started/quickstart), and every script, test and ops task imports them from there. Moving to a hosted or hardware wallet later is an edit to `accounts.ts` alone, so see the [recipes](/recipes). The differences worth knowing: * **No tags or dependencies.** Ordering is the order of your statements; a dependency is a variable. `getOrDeployVault` takes `token.address` because you have `token` in scope. * **`getOrDeploy` redeploys `on-change` by default** β€” like hardhat-deploy. Re-running sends no transaction unless the runtime bytecode, constructor args, or linked libraries changed; then it redeploys. Set `redeploymentStrategy: 'never'` to only ever reuse, or `'always'` to redeploy every run. See [idempotency](/concepts/idempotency). * **Named accounts become viem accounts.** `getNamedAccounts()` has no equivalent; build the clients you need, one per signer. * **You get a contract object, not a receipt-ish struct.** `result.contract` is a typed viem contract, `contract.read.*` / `contract.write.*`, so there's no second `getContractAt` step. * **Multiple instances of one artifact** use `deploymentName` instead of a distinct contract name: ```ts await getOrDeployVault({ ...clients, args: [usdc], deploymentName: "Vault_USDC" }); ``` ## Step 4: Replace `hre.deployments.get` in your app hardhat-deploy hands app code addresses through the Hardhat runtime or an `--export` bundle. deployoor's records are plain JSON, so [`@deployoor/wagmi`](/guides/consumption) feeds them to `@wagmi/cli`: ```ts // wagmi.config.ts import { defineConfig } from "@wagmi/cli"; import { actions } from "@wagmi/cli/plugins"; import { deployments } from "@deployoor/wagmi"; export default defineConfig({ out: "src/generated.ts", plugins: [deployments({ path: "./deployments" }), actions()], }); ``` ```bash npx wagmi generate ``` The same contract on several chains folds into one entry with an `address` map keyed by chainId. The generated file never imports deployoor. Coming from `rocketh-export --ts`, this is the same idea with wagmi's codegen instead of a first-party exporter. ## Step 5: Keep or drop hardhat-deploy Both can coexist indefinitely, they read different folders. When you're ready: ```bash pnpm remove hardhat-deploy hardhat-deploy-ethers ``` Remove `require("hardhat-deploy")` from `hardhat.config.js`, delete `deploy/`, and keep the old `deployments/` folder in git history as a record of what those addresses were. Optionally add [`@deployoor/hardhat`](/guides/hardhat) so `hardhat compile` regenerates deployers automatically, the way hardhat-deploy hooked into compile: ```js require("@deployoor/hardhat"); // Hardhat 2, use "@deployoor/hardhat/v3" on Hardhat 3 ``` ## Things that won't carry over * **Proxy and diamond deployments.** deployoor has no proxy support yet. `register` the proxy address so your app can reach it, but keep hardhat-deploy for upgrades until [proxies land](https://github.com/raycashxyz/deployoor/blob/main/TODO.md). * **`numDeployments`.** deployoor keeps one current record per (chain, deployment name), now with an append-only [`history`](/concepts/deployment-records#history) of every (re)deploy and why β€” plus git history. * **`fetchIfDifferent` / bytecode-diff behaviour.** This is now the default (`on-change`): a changed contract redeploys automatically. See [idempotency](/concepts/idempotency). * **Verification of imported records.** As noted above, imported contracts have no compiler metadata attached. Hit something this guide doesn't cover? [Open an issue](https://github.com/raycashxyz/deployoor/issues), migration gaps are the fastest way to get something prioritised. # CLI ```bash npx deployoor ``` ## Commands ### `init` Scaffold `deployoor.config.ts` if absent. Does not install packages. ```bash npx deployoor init ``` **Optional.** Every config option has a default, so `generate` works with no config file at all β€” the generated deployers carry the defaults inline. Run `init` when you want to change something: a different `out` or `deploymentsPath`, an `include` filter, a `redeploymentStrategy`, or plugins. The scaffold is filled in from what your project looks like, so it doubles as a report of what deployoor resolved: ```ts export default defineConfig({ // Detected: foundry, artifacts in artifacts (from foundry.toml). // deployoor reads that from your framework's own config, so leave this unset unless you // want to override it. // artifactsPath: "artifacts", out: "./deployers", // generated deployers β€” commit them deploymentsPath: "./deployments", // the deployment record β€” commit it // include: ["Token", "Vault"], // default: every contract with bytecode plugins: [], }); ``` `artifactsPath` is commented out deliberately, even when your framework's config moves the directory: deployoor reads that config itself, so writing the value here would copy a setting that already has an owner and let the copy drift from it. ### `generate` Read compiled artifacts and write typed deployers to `./deployers` (or your configured `out` path). ```bash forge build && npx deployoor generate # or npx hardhat compile && npx deployoor generate ``` Output: ``` deployoor: generated 3 file(s) ``` Artifact paths come from your framework's own config (`paths.artifacts` in `hardhat.config.*`, `out` in `foundry.toml`), so a moved build directory needs no deployoor config. Override with [`artifactsPath`](/guides/configuration) when neither states it. If `deployoor` and `viem` are not in your `package.json`, the generated deployers would not compile, so `generate` offers to add them with the package manager it detects from your lockfile: ```text deployoor: run `pnpm add -D deployoor viem` now? [y/N] ``` Only `y` installs. With no TTY (CI, a piped run) it never asks and exits with the command instead. Everything it writes is meant to be [committed](/concepts/version-control), so it also checks whether git is ignoring its own output, and offers to remove the rule: ``` deployoor: git is ignoring output that is meant to be committed: .gitignore:4 (`deployers`) ignores deployers/ β€” the generated deployers, which a fresh clone cannot typecheck or deploy without deployoor: remove line 4 of .gitignore now? [y/N] ``` Only `y` edits anything, and with no TTY it reports and changes nothing β€” so CI never rewrites a `.gitignore`. The answer comes from `git check-ignore`, so nested ignore files and a `!deployers/` you already added are accounted for. A rule broader than deployoor's output (`build`, when `out` is `./build/deployers`) or one outside the project (`.git/info/exclude`, a global `core.excludesFile`) is reported and left alone. See [upgrading](/guides/upgrading#3-commit-deployers). `--json` prints the file list instead, as the only thing on stdout: ```bash npx deployoor generate --json ``` ```json { "files": ["deployers/types/Counter.ts", "deployers/Counter.ts", "deployers/index.ts"] } ``` Paths are relative to the project root. `--json` also means unattended: neither question above is asked, a missing `deployoor`/`viem` fails with the install command rather than offering to run it, and the `.gitignore` advice goes to stderr. `generate` accepts nothing else on its command line β€” an unknown option, a stray argument, or `--json=true` fails before anything is read or written, rather than silently generating in the wrong mode. ### `verify` Verify already-deployed contracts on a block explorer, reading only committed data. Nothing is recompiled and no artifact directory is touched: a deployment record carries the chain id, address, constructor args and libraries, and the pinned sources it points at (`sourcesHash` β†’ `deployments/sources/.json`) carry the fully-qualified contract name, the compiler version and the standard-json input. That is the complete verification payload. ```bash npx deployoor verify npx deployoor verify --network sepolia npx deployoor verify --contract Counter --plugin etherscan ``` Submission goes through the plugins in your `deployoor.config.ts`, so whatever you configured is what runs: ```ts import { defineConfig } from "deployoor"; import { etherscan } from "@deployoor/etherscan"; export default defineConfig({ plugins: [etherscan({ apiKey: process.env.ETHERSCAN_KEY })], }); ``` Specifically, it calls each plugin's [`onVerify`](/guides/plugins#onverify) hook β€” its own hook, not the deploy-time one. A plugin that does not implement `onVerify` is skipped, which is why a notifier like `@deployoor/slack` stays quiet here with nothing to configure. If none of your plugins implements it, the command fails and names the ones you have. Filters (all optional; omit them to walk every record): | Flag | Matches | | ------------------- | ---------------------------------------------------------------------------------------- | | `--network ` | the network key (`11155111-sepolia`), its chain id (`11155111`), or its slug (`sepolia`) | | `--contract ` | a deployment name or a contract name, case-insensitive | | `--plugin ` | verify with only this plugin, when several implement `onVerify`. Repeatable | The three `--network` forms need the whole record set to resolve against, so they assume the default `fsStore`. A [custom `StoreAdapter`](/concepts/deployment-stores) that implements no `listAll` can only be asked for one network by its exact key, and says so rather than reporting that nothing matched. Output: ``` deployoor: checked 3 record(s) through etherscan verified 11155111-sepolia/Counter at 0x5FbDB…0aa3 (etherscan) unverifiable 1-ethereum/OldToken at 0x6B175…1d0F no sourcesHash β€” this record's verification sources were never pinned … skipped 1-ethereum/USDC at 0xA0b86…eB48 registered external contract β€” deployoor did not deploy it … deployoor: 1 verified, 1 unverifiable, 1 skipped ``` Exit code is non-zero when any selected record failed verification **or** could not be verified at all, so `deployoor verify` works as a CI check. Two outcomes are not failures: * **`skipped`** β€” an externally `register`ed contract (USDC and friends). You did not deploy it, so there are no sources to submit. * **`unverifiable`** β€” the record has no `sourcesHash`, or the blob it names is gone. The fully-qualified contract name lives only in the sidecar, so there is no way back to it without recompiling. Records written before deployoor pinned sources are in this bucket; redeploy to fix, or verify those with your explorer's own tooling. The run reports them and keeps going. #### `--json` `--json` prints the same report as one JSON document, for a script or an agent that would otherwise have to parse the summary above: ```bash npx deployoor verify --json ``` ```json { "ok": false, "plugins": ["etherscan"], "counts": { "verified": 1, "failed": 0, "unverifiable": 1, "skipped": 0 }, "results": [ { "deploymentName": "Counter", "contractName": "Counter", "networkName": "11155111-sepolia", "chainId": 11155111, "address": "0x5FbDB2315678afecb367f032d93F642f64180aa3", "outcome": { "status": "verified", "plugins": ["etherscan"] } }, { "deploymentName": "OldToken", "contractName": "OldToken", "networkName": "1-ethereum", "chainId": 1, "address": "0x6B175474E89094C44Da98b954EedeAC495271d0F", "outcome": { "status": "unverifiable", "detail": "no sourcesHash β€” …" } } ] } ``` `counts` always carries all four keys, zeros included. Each entry in `results` is one deployment record's outcome: `status` is `verified`, `failed`, `unverifiable` or `skipped`, and it comes with `plugins` when verification ran, `failures` (`{ plugin, error }`) when it failed, and `detail` otherwise. The exit code is the same as without the flag. Stdout is the document and nothing else. Plugins keep streaming their progress lines (`[etherscan] … verified`), on stderr, so `npx deployoor verify --json > report.json` leaves a file that parses and a terminal you can still watch. ## Flags ``` usage: deployoor Commands: init write deployoor.config.ts (optional β€” generate defaults without one) generate read compiled artifacts and write typed deployers verify verify recorded deployments on a block explorer (no recompile) generate options: --json print the generated file list as JSON (never prompts) verify options: --network only records on this network β€” `11155111-sepolia`, `11155111`, or `sepolia` --contract only this deployment or contract name --plugin verify with only this configured plugin (repeatable) --json print the report as one JSON document on stdout, nothing else Options: -h, --help show this help -v, --version show deployoor version ``` # Packages ## Core ### [`deployoor`](https://www.npmjs.com/package/deployoor) Deploy engine, codegen, and CLI (`init`, `generate`, `verify`). Reads Hardhat/Foundry artifacts, emits typed deployers, records deploys to `deployments/`. Exports: `deployoor`, `deployoor/plugin`, `deployoor/generate` β€” the last is the programmatic `generateDeployers`, which is what `@deployoor/hardhat` runs after each compile. ## Plugins Lifecycle hooks β€” each peer-depends on `deployoor` and imports only `deployoor/plugin`. | Package | npm | Description | | ----------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- | | `@deployoor/etherscan` | [npm](https://www.npmjs.com/package/@deployoor/etherscan) | Etherscan V2 verification (one key, all chains) | | `@deployoor/sourcify` | [npm](https://www.npmjs.com/package/@deployoor/sourcify) | Sourcify v2 verification (keyless) | | `@deployoor/blockscout` | [npm](https://www.npmjs.com/package/@deployoor/blockscout) | Verification on any Blockscout instance | | `@deployoor/routescan` | [npm](https://www.npmjs.com/package/@deployoor/routescan) | Routescan verification (mainnet/testnet from the chain id) | | `@deployoor/slack` | [npm](https://www.npmjs.com/package/@deployoor/slack) | Slack notifications on deploy | All four verifiers implement [`onVerify`](/guides/plugins#onverify), so [`deployoor verify`](/reference/cli#verify) can replay them after the fact β€” see [Verify contracts](/guides/verify). ## Toolchain & consumption | Package | npm | Description | | -------------------- | ------------------------------------------------------- | --------------------------------------------------------- | | `@deployoor/hardhat` | [npm](https://www.npmjs.com/package/@deployoor/hardhat) | Auto-regenerate deployers on `hardhat compile` | | `@deployoor/wagmi` | [npm](https://www.npmjs.com/package/@deployoor/wagmi) | `@wagmi/cli` plugin sourcing from `deployments/` | | `@deployoor/testing` | [npm](https://www.npmjs.com/package/@deployoor/testing) | `createTestClients()` β€” in-memory EVM + store (Node β‰₯ 22) | ## Ecosystem ### [`fhevm-tevm-mocks`](https://www.npmjs.com/package/fhevm-tevm-mocks) Separate from the deploy core β€” Tevm-native adapter for Zama FHEVM mock tests. Wires FHE host contracts and relayer handlers into a Tevm instance you own. Developed in [its own repo](https://github.com/raycashxyz/fhevm-tevm-mocks). ## Install matrix ```bash # minimum pnpm add -D deployoor viem # hardhat auto-generate pnpm add -D @deployoor/hardhat deployoor viem # wagmi consumption pnpm add -D @deployoor/wagmi @wagmi/cli # verification β€” every verifier; keep the explorers you use pnpm add -D @deployoor/etherscan @deployoor/sourcify @deployoor/blockscout @deployoor/routescan # testing pnpm add -D @deployoor/testing vitest ``` # About deployoor **deployoor is a viem-first contract deployment library for EVM chains, published as open source under the MIT license.** It is a developer tool you install into your own project, in the shape of [`@wagmi/cli`](https://wagmi.sh/cli) or Prisma: a CLI plus a set of generated TypeScript functions. You run it to deploy a contract once, keep a plain JSON record of which contract is deployed on which chain, and then use those contracts as fully typed [viem](https://viem.sh) objects in scripts, tests, and your app. [Introduction](/introduction) is the long version of that claim, and [Comparisons](/comparison) puts it next to Hardhat, Hardhat Ignition, and hardhat-deploy. ## The name deployoor is spelled with a double o. It is the `-oor` agent noun crypto built out of "deploy", the way buidloor comes from build and hodloor from hold, so the name reads as "the thing that deploys". It is unrelated to Deployer, the PHP deployment tool, and to deployor. ## Who publishes it deployoor is published by **[Raycash](https://www.raycash.xyz)** and maintained by **Valerio Leo**, the copyright holder named in the [MIT license](https://github.com/raycashxyz/deployoor/blob/main/LICENSE). The source, issue tracker, and release history are public: * **Source:** [github.com/raycashxyz/deployoor](https://github.com/raycashxyz/deployoor), a monorepo holding the engine, the plugins, the docs site, and dogfooding examples. * **Packages:** [`deployoor`](https://www.npmjs.com/package/deployoor) on npm, with the ecosystem packages published under the [`@deployoor`](/packages) scope. * **Releases:** every version and what changed is in the [changelog](/changelog), generated from the repository's own release notes. ## What it is not deployoor is a library, not a service. There is no account to create, no dashboard, no API key, and nothing to pay for. It runs entirely on your machine or in your CI, and it holds no keys of its own: you pass in a viem wallet client, so the signer stays whatever you already use, from a local private key to [Privy](/recipes/privy), [Turnkey](/recipes/turnkey), [Coinbase CDP](/recipes/coinbase-cdp), or [Openfort](/recipes/openfort). The tool makes no network calls of its own beyond the RPC endpoint you give it and, if you enable a verifier plugin, the block explorer you point it at. See [privacy](/privacy) for the detail. ## Status deployoor is pre-1.0 and released early on purpose, so treat minor versions as potentially breaking and read the [changelog](/changelog) before upgrading. Deploying, the deployment record, the plugin model, verification from records, and the wagmi bridge are all in daily use. The [packages page](/packages) lists what exists today, and the issue tracker is where the rest is argued out in public. Questions, bug reports, and requests all have a home on the [contact page](/contact). If you are an AI agent deciding whether deployoor fits a task, read [AGENTS.md](https://deployoor.dev/AGENTS.md) or [llms.txt](https://deployoor.dev/llms.txt) instead of this page. # Changelog Release history for `deployoor` and its ecosystem packages. ## deployoor@0.10.1 (2026-09-14) ### Patch Changes * 5ef32ea: End each written `deployments/**.json` and `deployments/sources/*.json` with a newline. The files were already indented; without the final newline git printed `\ No newline at end of file` on every record, and the first `prettier --write` over the project rewrote all of them. ## deployoor@0.10.0 (2026-09-10) ### Minor Changes * 8e761ba: `deployoor verify --json` and `deployoor generate --json`, for scripts and agents that should not have to parse a summary `verify --json` prints the whole run as one JSON document and nothing else: ```json { "ok": false, "plugins": ["etherscan"], "counts": { "verified": 1, "failed": 0, "unverifiable": 1, "skipped": 0 }, "results": [ { "deploymentName": "Counter", "contractName": "Counter", "networkName": "11155111-sepolia", "chainId": 11155111, "address": "0x5FbDB2315678afecb367f032d93F642f64180aa3", "outcome": { "status": "verified", "plugins": ["etherscan"] } } ] } ``` `results` is the `VerifyResult` list verbatim β€” the same fields and the same discriminated `outcome` the summary prints from, so the two cannot drift apart β€” and `counts` carries all four statuses whether or not anything landed in them. The exit code is unchanged: non-zero when any record failed or could not be verified. Stdout stays a single JSON value. Verifier plugins keep streaming their progress (`[etherscan] … verified`), on stderr, so `deployoor verify --json > report.json` leaves a file that parses and a terminal that still shows the run. `generate --json` prints `{ "files": [...] }`, the written paths relative to the project root. It implies unattended: neither the install offer nor the `.gitignore` question is asked, a missing `deployoor`/`viem` fails with the command to install them, and the `.gitignore` advice goes to stderr. `verify`'s argument parser now handles boolean flags as their own kind, so `--json` consumes no value and the token after it is still reported as an unexpected argument. `--json=true` is rejected as bad usage. `generate` validates its command line the same way β€” an unknown option, a positional argument, or `--json=true` fails before anything is read or written, where it used to be silently ignored. ## deployoor@0.9.0 (2026-08-27) ### Minor Changes * 915141e: Writes on a deployed contract no longer need an explicit `{ account, chain }`. `DeployResult.contract` was typed against a bare `WalletClient`, whose `chain` and `account` are both `Chain | undefined` / `Account | undefined`. viem makes the second argument of a write mandatory whenever either could be `undefined`, so `contract.write.foo(args)` demanded `{ account, chain }` even though the deployer had already bound both. The contract type now names the shape the engine actually runs on (`clientsLayer` fails with `NoChainOnClient` without a chain and an account), so a single-argument write typechecks: ```ts const { contract } = await getOrDeployCounter({ walletClient, publicClient, args: [1n, owner] }); await contract.write.increment(); // was: increment({ account, chain }) ``` Passing the options explicitly still works, so existing scripts keep compiling. `register` now resolves to a contract that matches the client it was handed. It broadcasts nothing, so it accepts clients a deploy would reject, and each gets the write surface it can actually use: a wallet client with an account and a chain writes with no second argument; one binding neither still has `write` but must pass `{ account, chain }`; a public client alone gets no `write` at all. Previously every case was typed writable-and-bound, so `register({ publicClient })` followed by `contract.write.foo(...)` typechecked and then threw. `@deployoor/testing`'s clients are typed as bound (they always were, at runtime), so `contract.write.foo(args)` works single-argument in tests too. `TestWalletClient` is exported for annotating helpers. New exported types on `deployoor`: `BoundWalletClient`, `DeployedContract`, `UnboundContract`, `ReadOnlyContract`, and `Register`. `DeployResult` takes an optional second type parameter for the contract type, defaulting to the writable one. ## deployoor@0.8.0 (2026-08-23) ### Minor Changes * 1722559: Match generated relative imports to the project's TypeScript setup, so `deployers/` typechecks under `moduleResolution: node16`/`nodenext` (Hardhat 3's default) as well as `bundler`. Those modes reject extensionless relative specifiers with TS2835, so a strict-ESM project could not `tsc` a deployoor project without loosening resolution. `deployoor generate` now reads the project's `tsconfig.json` (following `extends`) and emits `./types/Counter.js` only for the resolution modes that require it. This includes projects that only set `module`, since `node16`, `node18` and `node20` all imply `moduleResolution: node16`. Every other mode keeps the extensionless form, since a `.js` specifier fails to resolve where it is not mapped back to `.ts` (webpack without `resolve.extensionAlias`, ts-jest without a `moduleNameMapper`). Override with the new `importExtension` config option: `'auto'` (default), `'none'`, or `'js'`. ## deployoor@0.7.1 (2026-08-12) ### Patch Changes * 13f0991: Say clearly when a hardhat.config cannot be read, instead of guessing at `paths.artifacts` deployoor finds a Hardhat project's artifacts by importing `hardhat.config.*`. A config that registers a plugin β€” `require("@deployoor/hardhat")`, or any plugin a real project uses β€” **cannot be imported outside a Hardhat run**: it throws `HH5: HardhatContext is not created`. deployoor then falls back to the framework default. That is worth knowing about, because the shape hides it: `generate` succeeds while a *deploy* fails, since the Hardhat plugin hands the resolved path over directly and only a deploy or a test has to read the config itself. Set `artifactsPath` in `deployoor.config.ts` for those projects β€” `examples/custom-paths` shows it, with the reasoning. A text fallback that read the literal `paths.artifacts` out of the source was tried and removed before release. Four rounds of review found four shapes where it returned the **wrong** directory, and a wrong artifacts directory is the worst outcome available here: if stale artifacts happen to sit there, it deploys old bytecode and reports success. Reading a value out of arbitrary JavaScript needs a parser, which is far too much machinery for a fallback whose alternative is one line of config. The durable fix is for `generate` to record the path it resolved β€” it already receives the correct one β€” so a deploy never re-derives it. That is a separate change. ## deployoor@0.7.0 (2026-08-11) ### Minor Changes * 2103372: `generate` and `init` notice a `.gitignore` rule that would keep deployoor's output out of the repo, and offer to remove it Everything deployoor writes is now meant to be committed β€” `deployments/` always was, and the deployers became small enough to diff once they stopped inlining `standardJsonInput`. The docs used to say the opposite, so the projects most likely to carry a `deployers` ignore rule are the ones that followed them. Both commands now check the configured `out` and `deploymentsPath` and report any rule covering them: ```text deployoor: git is ignoring output that is meant to be committed: .gitignore:4 (`deployers`) ignores deployers/ β€” the generated deployers, which a fresh clone cannot typecheck or deploy without deployoor: remove line 4 of .gitignore now? [y/N] ``` Only an explicit `y` edits a file, and with no TTY nothing is asked and nothing changes, so CI never rewrites a `.gitignore`. Accepting also removes a deployoor comment introducing the rule, rather than leaving it above nothing. The question goes to `git check-ignore`, not to a parser here, so nested ignore files, `.git/info/exclude`, `core.excludesFile` and negations are all accounted for β€” a `!deployers/` you already added means you are not asked. Two cases are reported and left alone: a pattern broader than deployoor's output (`build`, when `out` is `./build/deployers`), because removing it would un-ignore everything else under it; and a rule in a file outside the project, because it is clone- or machine-wide. Outside a git repository, or with no `git` on PATH, nothing is reported rather than guessed. Nothing here runs from `generateDeployers`, only from the CLI β€” a build hook like `@deployoor/hardhat` is the wrong place to ask a question or to repeat the same advice on every compile. `init` also scaffolds from the project rather than from a fixed template: the config it writes names the detected toolchain and where deployoor resolved the artifacts directory. `artifactsPath` stays commented out even when your framework's config moves it, since deployoor reads that config itself and a copy would be free to drift. `runInit` is now async as a result. Two safety details worth naming. The "is this file inside the project" test compares **canonical** paths: git does not follow a symlinked `.gitignore` but does follow one used as `core.excludesFile`, so a link inside the project can name a target outside it, and a lexical comparison would call that editable and then write through the link. And the advice for a broader pattern no longer suggests a bare negation β€” git does not descend into an excluded directory, so `!build/deployers/` under a `build` rule does nothing; it now says to move `out` or widen the rule to `build/*` first. `runInit` creates the config with `wx` rather than checking `existsSync` first, since detection is async and a check before it left a window in which a concurrent run's file would be truncated. * 197cfb9: `deployers/` is now small enough to commit, and the deploy path reads the rest from your compiled artifacts. **What `generate` emits changed.** A generated artifact module carried the abi *and* the bytecode, the compiler settings, and `standardJsonInput` β€” the whole compilation unit's source text, inlined once per contract, which is the only reason the folder was large. It now carries what cannot be recovered from disk: ```ts export const counterArtifact = { name: "Counter", fullyQualifiedName: "contracts/Counter.sol:Counter", abi, } satisfies GeneratedArtifact; ``` The abi stays inlined `as const` because that literal type is the whole point: it types `args` and `contract.read.*`, and a JSON import widens `"uint256"` to `string`, which abitype cannot use. Everything else is loaded from the compiled artifact at deploy time, keyed by `fullyQualifiedName`. So the emitted files only change when your interface does β€” a solc patch bump or an implementation-only edit no longer touches them. **Deploying now requires compiled artifacts, and says so when they are missing.** Two new fatal errors: `ContractArtifactNotFound` names the contract and lists what *was* compiled, so a rename is obvious; `GeneratedArtifactStale` fires when the committed abi no longer matches the artifact and prints the difference. ```text Counter's abi no longer matches its compiled artifact. Re-run `deployoor generate`, then deploy again. + function decrement() nonpayable - function reset() nonpayable ``` That check is what makes a committed `deployers/` safe. Without it a stale abi would encode constructor args against the old interface and write the old abi into the deployment record, which is what every consumer then reads. The comparison is canonical, so solc key order, abi entry order and `internalType` never trigger it. **`defineDeployer` accepts either shape.** A full `TypedArtifact` is used as-is and never touches the filesystem, so hand-built artifacts and in-memory compilation keep working unchanged β€” `TypedArtifact` and `Artifact` are not modified. Artifacts are read fresh on every resolve, so a recompile is always picked up; a scan measures well under a millisecond against a deploy that spends seconds on the network. * 5655eb2: New command: `deployoor verify` β€” verify already-deployed contracts on a block explorer after the fact, from committed data alone. Plus a new plugin hook, `onVerify`, which is what it calls. ```bash npx deployoor verify npx deployoor verify --network sepolia npx deployoor verify --contract Counter --plugin etherscan ``` Nothing is recompiled and no artifact directory is read. A deployment record already carries the chain id, the address, the constructor args and the linked libraries; the sources it pins (`sourcesHash` β†’ `deployments/sources/.json`) carry the fully-qualified contract name, the compiler version and the standard-json input. Together that is the whole verification payload β€” which is what the pinned sidecar was written for, and until now nothing read it. **`onVerify` is a new hook on `DeployPlugin`**, and `deployoor verify` calls only that: ```ts readonly onVerify?: (ctx: VerifyContext, deps: PluginDeps) => Awaitable; interface VerifyContext { readonly deployment: DeploymentRecord; readonly metadata: ContractMetadata; } ``` `VerifyContext` is deliberately not a `DeployedContext`, and deliberately small. Nothing was deployed, so there is no `receipt` and no meaningful `reused`; `metadata` is **required** rather than optional, because a record whose sources were never pinned is reported unverifiable and never reaches a plugin; and there is no `options`, because a plugin instance already closes over its own configuration. A verifier written against it needs no undefined-checks and cannot mistake a verify run for a deploy. **Breaking for third-party verifier plugins:** a plugin that only implements `onContractDeployed` will not be called by `deployoor verify` β€” it is skipped, silently, and if no configured plugin implements `onVerify` the command fails and names the plugins you do have. Implement `onVerify` over the same body as `onContractDeployed` (see the plugins guide) and it works both at deploy time and after the fact. Deploy-time behaviour is untouched: `onContractDeployed` and `onDeployFailed` are called exactly as before, and this is also what stops a notifier like `@deployoor/slack` from firing on a verify run. Filters: `--network` matches the network key, its chain id, or its slug; `--contract` matches a deployment name or a contract name; `--plugin` narrows to one verifier when several are configured. All optional. The exit code is non-zero when any selected record failed verification or could not be verified at all, so this works as a CI check. Per-record outcomes are reported and the run continues: ```text deployoor: checked 3 record(s) through etherscan verified 11155111-sepolia/Counter at 0x5FbDB…0aa3 (etherscan) unverifiable 1-ethereum/OldToken at 0x6B175…1d0F no sourcesHash β€” this record's verification sources were never pinned … skipped 1-ethereum/USDC at 0xA0b86…eB48 registered external contract β€” deployoor did not deploy it … deployoor: 1 verified, 1 unverifiable, 1 skipped ``` A record with **no** `sourcesHash` is `unverifiable`, not a crash: the fully-qualified name exists only in the sidecar, so there is no route back to it without recompiling. That covers records written before sources were pinned, and records written by a store that pins none. Externally `register`ed contracts are `skipped` β€” you did not deploy them, so there is nothing to submit β€” and do not fail the run. `StoreAdapter` gains an optional `listAll()` (implemented by `fsStore` and `memoryStore`), because `list` is per-network and a repo-wide walk needs the whole set. A store that omits it can still be verified one `--network` at a time. Three edge cases in the command that the first round missed: * `--network=` (and `--contract=` / `--plugin=`) with nothing after the equals sign parsed to an empty string, which is not `undefined`, so it became an active filter matching no record β€” reported as "no deployment records matching", as though the repo held none. It is now the same "needs a value" error as a trailing `--network`. * `runVerify({ plugins: [] })` selected no plugin rather than every plugin, so each record ran zero hooks, collected zero failures, and was reported **verified** with `ok: true` β€” a CI check passing without verifying anything. An empty list is now rejected. The CLI never produced one; the exported API could. * `store.listAll` and `store.readSources` were read into locals and called detached, so a `StoreAdapter` implemented as a class (which the docs invite) ran them with `this` unbound and crashed. They are bound to the store now. * fc43ab6: `deployoor generate` now works on a stock project with no deployoor config at all, and figures out a non-stock one by reading the framework's own config. **`deployoor.config.ts` is optional.** `generate` used to throw `no deployoor.config found` when a project had no config file. Every `Config` option already had a default, so it now generates with those defaults: ```bash npx hardhat compile && npx deployoor generate ``` Deployers emitted for a project with no config carry the defaults inline (`defineDeployer(artifact, {} satisfies Config)`) rather than importing a config module, so the emitted tree has nothing to resolve. Projects that do have a `deployoor.config.*` are unaffected: the deployers still import it, and running `deployoor init` later is a type-compatible swap. `init` keeps its job, which is taking control of the defaults (paths, `include`, `redeploymentStrategy`, plugins). **The artifacts directory is read from your framework's config.** deployoor now takes `paths.artifacts` from `hardhat.config.*` and `out` from the active `foundry.toml` profile, so a project that keeps its build output somewhere other than `artifacts/` or `out/` needs no deployoor config either. Reading hardhat.config is best-effort, since it is arbitrary user code that can import plugins or fail to load; a failure falls back to the framework default rather than aborting the run. `FOUNDRY_PROFILE` is honoured. **New `artifactsPath` config option**, for the cases the above cannot cover (an output directory that neither config states). It takes precedence over both. **`ArtifactsNotFound` says what actually went wrong.** It used to always advise "Compile first", which is misleading for a project that did compile and writes elsewhere, and it never said that no toolchain could be detected. It now names the detected framework and the file that gave it away, and distinguishes: nothing compiled yet, an output directory the framework's own config already points at (so there is nothing left to configure), a wrong `artifactsPath`, and no toolchain at all, which lists every marker it looked for. **`generate` offers to install its own dependencies.** The generated deployers import `deployoor` and `viem`, so generating into a project that has not declared them leaves a tree that cannot compile. Instead of only naming the command, `generate` now asks, using the package manager it detects from your lockfile: ```text deployoor: run `pnpm add -D deployoor viem` now? [y/N] ``` Only an explicit `y` installs. Without a TTY (CI, a piped run) it never prompts and fails with the command to run, so nothing is installed behind your back. ### Patch Changes * dd8cb2a: Groundwork for committable `deployers/`, inert on its own. Adds the `GeneratedArtifact` type β€” the shape `deployoor generate` will emit once the deployers carry only what cannot be recovered from a compiled artifact β€” and an abi canonicaliser that answers "is this the same interface?" while ignoring solc key order, abi entry order and `internalType`. Nothing emits or consumes either yet, so there is no behaviour change. The release note for the feature lands with the change that wires them up. ## deployoor@0.6.0 (2026-07-30) ### Minor Changes * 1cb8250: Use viem's `Hex`/`Address` types, cap peer ranges below the next major, and remove the deprecated `force` option. **BREAKING (pre-1.0)** * The `force` option is **gone** (it was deprecated one release ago and never shipped in that state): replace `force: true` with `redeploymentStrategy: 'always'` and `force: false` with `redeploymentStrategy: 'never'`. * `deployoor`'s zod validators are renamed to free the bare names for viem's types: `Hex` β†’ `HexSchema`, `Address` β†’ `AddressSchema`, `Bytecode` β†’ `BytecodeSchema` (matching the existing `AbiSchema`). The exported record/artifact **types** are unchanged β€” they now spell their hex fields as viem's `Hex` and `Address`, which are structurally identical to the `` `0x${string}` `` they replace, so consumers need no change. **Peer ranges** viem 3 is imminent and `>=2` would have accepted it silently. Every `viem` peer is now `^2`; `@wagmi/cli` is `^2`, `@tevm/compiler` is capped `<2`, and `solc` `<0.9`. The plugin packages' `deployoor` peer was `*` β€” accepting any engine version, including breaking pre-1.0 minors β€” and is now `>=0.5.0 <1.0.0`. `@deployoor/testing` and `fhevm-tevm-mocks` additionally declared `viem >=2.49`, a floor their own catalog version (2.44.2) did not satisfy and that nothing required (tevm itself asks for `^2.37.9`). `@deployoor/wagmi` now declares the `viem` peer it always implicitly relied on. **Internal** * One `ensureHexPrefix` helper in `artifacts/parse.ts` replaces two copies of the "0x-prefix raw solc output" logic (`parse.ts` and `artifacts/tevm.ts`). It is deliberately not viem's `toHex`, which *encodes* a string rather than prefixing it, and cannot assert `isHex` because unlinked bytecode legitimately contains `__$…$__` placeholders. * Fixed-width hex fields are built with `numberToHex(n, { size })` / `concatHex` / byte-indexed `slice` instead of `.toString(16).padStart(...)` and character arithmetic. * No `let`, `var`, or reassignment anywhere in the codebase, enforced by oxlint (`no-var`, `prefer-const`, `no-const-assign`, `no-param-reassign`, `no-plusplus`). The accumulator in `codegen/generate.ts` is a `.flatMap`, the tevm fully-qualified-name dedupe is a `.reduce`, and the deploy tests get their EVM clients from a top-level `await` rather than a `let` filled in by `beforeAll`. * Dropped a duplicate `tevm` devDependency in `fhevm-tevm-mocks` (already a `dependencies` entry at the same version). * 1cb8250: Harden `redeploymentStrategy` and make the pinned verification sources content-addressed. * **A v1 record no longer redeploys on a comment-only recompile.** The v1 fallback compared raw creation bytecode, which carries the same trailing CBOR metadata hash as the runtime code β€” so upgrading deployoor and recompiling redeployed every existing contract on the first run under the new `'on-change'` default. Both sides are now metadata-stripped. * **Constructor args are compared by their ABI encoding**, not their JSON shape, so `1`, `1n`, and the `"1"` a record stores are one value. This is the canonicalisation `identityHash` already applied to the whole tuple, so the component diff and the hash now agree by construction. Both sides of a pair must encode for the encoded comparison to count: viem's address encoder accepts an all-lowercase address but rejects a non-checksummed mixed-case one, so encoding per value would compare an ABI key against a JSON key β€” two key spaces that can never be equal β€” and report a change that isn't one. Address casing is folded away by the shared JSON fallback. * **`stripMetadata` is total.** It parsed the trailing bytes as a length without checking they were hex, so bytecode ending in an unlinked `__$…$__` library placeholder threw β€” surfacing as an untagged defect from the diff path, which documents itself as non-throwing. * **Pinned verification sources are content-addressed**, at `deployments/sources/.json` with a `sourcesHash` on the record, replacing the per-record `.sources.json` sidecar. A standard-json input is the whole compilation unit, so the previous layout meant one copy of every source file per contract *per chain*; identical input is now stored once. `reset` collects blobs no remaining record references instead of deleting by name, so a blob another chain still points at survives. Pinning is best-effort: the deploy is already confirmed by then, so a store that cannot write sources logs a warning and the record is written without a `sourcesHash` rather than the whole call failing. Custom `StoreAdapter` implementations need updating: `writeSources(hash, sources)` and `readSources(hash)` are keyed by the content hash (typed `Hex`) rather than `(network, name)`, and `removeSources` is replaced by `pruneSources()`, which drops every blob no surviving record references. * **Records store a `codeHash`, not a second copy of the runtime bytecode.** Verification never reads it β€” a standard-json verify submits the pinned sources and the explorer recompiles β€” so the field is an artifact-side code identity, at 32 bytes instead of ~24KB of hex per contract per chain. It is `keccak` of the **metadata-stripped** runtime bytecode, so comparing it to on-chain code means stripping the trailing CBOR from `eth_getCode` first, and it will not match at all for a contract with `immutable` variables, whose deployed code has the values written in. It is omitted (rather than silently meaning something else) when the runtime bytecode still carries unlinked library placeholders, because viem's `keccak256` does not reject non-hex input β€” it falls through to hashing the text. * **`identityHash` is omitted rather than substituted** when the identity is not computable. It previously fell back to a bare code hash, which can never equal a real identity hash and so bought exactly one spurious redeploy; absent, the reuse test falls back to the component diff. * **`register` appends to history instead of replacing it.** Re-registering an external contract at a new address kept only the new entry and recorded no `supersededAddress`. Re-registering the *same* address appends nothing, so a repeated script run no longer grows the log. Upgrading: existing `deployments/**/.sources.json` files are no longer read or written, and nothing backfills them β€” a record only gains a `sourcesHash` when it is next deployed. Keep the legacy files until the records beside them carry a `sourcesHash`; for a deployment you never redeploy, the old sidecar stays the only pinned copy of its sources. * 1d04bfd: Add `redeploymentStrategy` and redeploy-on-change. `getOrDeploy` now decides reuse-vs-redeploy by a `redeploymentStrategy` β€” `'on-change'` (the new default), `'never'`, or `'always'` β€” settable per call, as a config default, or per chain via `redeploymentStrategyByChainId`. `'on-change'` redeploys when the **deploy identity** (metadata-stripped runtime bytecode + constructor args + linked library addresses) changes, so a redeployed dependency's new address cascades through the contracts that take it β€” while a comment-only recompile does not redeploy. Deployment records are now `schemaVersion: 2`: they carry a `codeHash`, an `identityHash`, and an append-only `history` of every (re)deploy with a descriptive `reason`/`summary` (v1 records still read, and upgrade in place on the next deploy). Each deploy also pins the exact solc standard-json input it used, so a deployment stays verifiable on a block explorer later β€” independent of the current source tree. BREAKING (pre-1.0): the default is now `'on-change'` rather than reuse-only, so a changed contract redeploys on re-run. Set `redeploymentStrategy: 'never'` (globally or per chain) to restore the old behaviour. The boolean `force` option is **removed**: replace `force: true` with `redeploymentStrategy: 'always'` and `force: false` with `redeploymentStrategy: 'never'`. ### Patch Changes * d7acf57: Update the package description to lead with what deployoor is: deploy EVM contracts from TypeScript with your own viem wallet, where a deploy is an artifact plus a client so scripts, tests, and your app share the same typed contract objects. No code change. ## deployoor@0.5.0 (2026-07-09) ### Minor Changes * ecab8f9: Add Hardhat 3 and tevm support to `deployoor generate`. * **Hardhat 3**: the Hardhat reader now handles both majors, keyed on the artifact's build-info linkage β€” Hardhat 2's `.dbg.json` β†’ build-info, and Hardhat 3's inline `buildInfoId` + split `build-info/.json` (`hh3-sol-build-info-1`). The standard-json input and `solcLongVersion` used for verification are read the same way from both. Uses `inputSourceName` for the fully-qualified name when present so verification matches the compiled source path. * **tevm**: a new adapter compiles a project's `.sol` sources directly with `@tevm/compiler` + a solc-js instance β€” no Hardhat or Foundry project required. A plain-`.sol` project is auto-detected (no Foundry/Hardhat markers + `.sol` under `src/` or `contracts/`); set `framework: "tevm"` in `deployoor.config.ts` (or add a `tevm.config.*`) and `sources` only to be explicit or when contracts live elsewhere. `@tevm/compiler` and `solc` are optional peers, lazy-loaded only for tevm projects, so the core stays dependency-light. `generate` is now async internally (the tevm adapter compiles on demand); the `deployoor generate` CLI and the exported `generateDeployers` keep the same signatures β€” `generateDeployers` was already `async` and returned a `Promise`, so programmatic callers already `await` it. New config fields: `framework` and `sources`. ## deployoor@0.4.0 (2026-07-03) ### Minor Changes * 7913ff9: Point repository metadata at `raycashxyz/deployoor` after transferring the GitHub org. ## deployoor@0.3.0 (2026-07-02) ### Minor Changes * 7c4faa2: Generate deployers for library-linked contracts, record the library map, and let `register` run with only a public client. * `deployoor generate` no longer silently drops a contract whose bytecode carries solc's unlinked library placeholders (`__$…$__`). The artifact and deployment-record bytecode boundary now accepts placeholders via a new `Bytecode` validator (`Hex` stays strict for addresses and tx hashes), so a library-dependent contract gets a typed `getOrDeploy` β€” its addresses are linked at deploy time from the `libraries` call option, and the deployment record now also stores that `libraries` map. * `deployoor generate` warns when an explicit `include` name matches no deployable contract (a typo, or a contract that failed to compile) instead of dropping it silently. * `register(...)` no longer requires a `walletClient`: it only records an existing address, so a `publicClient` is enough. Pass a wallet to record it as the registrant and get a writable contract back; omit it and the deployer is recorded as the zero address (read-only contract). ## deployoor@0.2.0 (2026-07-02) ### Minor Changes * 2a34f70: Harden deployment records and test helpers: records now carry `schemaVersion: 1`, use chain-id-based filesystem keys, guard chainId mismatches on reuse, warn on stale bytecode/constructor args, and write filesystem records atomically under a lock. `@deployoor/testing` now exposes tevm, cheatcodes, fixtures, and deployment-record seeding (seeded records are remapped onto the in-memory chain so `getOrDeploy` reuses them). Verifier plugins can retry reused deployments when artifact metadata is available, Slack can notify failed deploys, and the wagmi plugin uses Zod 4-safe validators with ABI drift checks. **BREAKING (pre-1.0):** the record folder layout changed from `deployments//` to `deployments/-/` (e.g. `deployments/sepolia/` β†’ `deployments/11155111-sepolia/`), and the record's `networkName` field now holds that composite key. Records written by 0.1.x are not read from the old location β€” move each folder to its new name (and update `networkName` inside the files, or let the next deploy rewrite them) before re-running deploy scripts, or `getOrDeploy` will redeploy. * 2a34f70: Expose `register` and `reset` as project-level entry points. `deployoor generate` now emits both in the deployers index (config-bound, scoped to the client's chain): `register({ walletClient, publicClient, deploymentName, address, abi })` records a contract you didn't deploy (e.g. USDC) with no transaction and returns its viem object, and `reset({ publicClient, deploymentName? })` forgets recorded deployment(s) so the next `getOrDeploy` redeploys. Adds the public factories `defineRegister` / `defineReset`. The older `name` spelling is accepted as a compatibility alias. Registered records are marked `kind: "external"`, and `register` will not overwrite a real deployment at the same `(chain, name)` β€” it fails with `DeploymentExists` (reset it first, or use a different name); re-registering an external record updates it. `reset` is a pure local-records operation and needs only a `publicClient` (no signer). Also documents `deploymentName` (defaults to the contract name) for deploying and tracking multiple instances of the same contract. * c12b352: `getOrDeploy` and `register` now resolve to a `DeployResult` β€” `{ contract, deployment, freshDeploy, receipt? }` β€” instead of the bare viem contract. `contract` is the typed viem object (same one as before); `freshDeploy` is `true` only when the call broadcast a deploy transaction (so it is `false` on idempotent reuse and always for `register`); `receipt` is the deploy receipt, present only on a fresh deploy; `deployment` is the full record. This lets a deploy script run one-time setup only when it actually deployed. **BREAKING (pre-1.0):** callers that used the return value as a contract must destructure it β€” `const token = await getOrDeployToken(...)` becomes `const { contract: token } = await getOrDeployToken(...)`. Also adds the `deployoor/generate` subpath, exporting `generateDeployers({ root })` β€” the programmatic form of `deployoor generate` (discover config β†’ read artifacts β†’ write typed deployers) so a build tool can run generation in process. `@deployoor/hardhat` uses it. ### Patch Changes * 4e505d0: Compat hardening from packaging/resolution audit: `sideEffects: false` on all publishable packages; `typesVersions` on `deployoor/plugin` and `deployoor/generate` for legacy `moduleResolution: "node"`; Node `>=20` engines on tevm-dependent packages; align tevm as a hard dependency and declare `viem >=2.49` where tevm requires it; document TypeScript-first codegen and CJS/ESM caveats; add a Windows CI smoke job. ## deployoor@0.1.0 (2026-06-30) ### Minor Changes * 15fab66: Initial public release of deployoor: viem-first contract deployment with a `deployments/` source of truth, idempotent `getOrDeploy` deployers, the `deployoor/plugin` lifecycle SDK, and the `@deployoor/wagmi` / `@deployoor/etherscan` / `@deployoor/sourcify` / `@deployoor/slack` plugins. ## @deployoor/testing@0.5.0 (2026-08-27) ### Minor Changes * 915141e: Writes on a deployed contract no longer need an explicit `{ account, chain }`. `DeployResult.contract` was typed against a bare `WalletClient`, whose `chain` and `account` are both `Chain | undefined` / `Account | undefined`. viem makes the second argument of a write mandatory whenever either could be `undefined`, so `contract.write.foo(args)` demanded `{ account, chain }` even though the deployer had already bound both. The contract type now names the shape the engine actually runs on (`clientsLayer` fails with `NoChainOnClient` without a chain and an account), so a single-argument write typechecks: ```ts const { contract } = await getOrDeployCounter({ walletClient, publicClient, args: [1n, owner] }); await contract.write.increment(); // was: increment({ account, chain }) ``` Passing the options explicitly still works, so existing scripts keep compiling. `register` now resolves to a contract that matches the client it was handed. It broadcasts nothing, so it accepts clients a deploy would reject, and each gets the write surface it can actually use: a wallet client with an account and a chain writes with no second argument; one binding neither still has `write` but must pass `{ account, chain }`; a public client alone gets no `write` at all. Previously every case was typed writable-and-bound, so `register({ publicClient })` followed by `contract.write.foo(...)` typechecked and then threw. `@deployoor/testing`'s clients are typed as bound (they always were, at runtime), so `contract.write.foo(args)` works single-argument in tests too. `TestWalletClient` is exported for annotating helpers. New exported types on `deployoor`: `BoundWalletClient`, `DeployedContract`, `UnboundContract`, `ReadOnlyContract`, and `Register`. `DeployResult` takes an optional second type parameter for the contract type, defaulting to the writable one. ## @deployoor/testing@0.4.0 (2026-08-18) ### Minor Changes * 5b5e8d2: Replace tevm with EDR as the in-memory EVM. `createTestClients()` keeps its zero-config shape and generated deployers work unchanged, but the engine underneath is now [EDR](https://github.com/NomicFoundation/edr), the Rust EVM behind Hardhat 3. Why: `tevm` could not be installed. It declares its ~40 sibling `@tevm/*` packages with caret ranges that float into a newer prerelease line, so `yarn add tevm` aborts on an unpublished `@evmts/zevm` platform package, and `npm i tevm` installs a mixed tree that throws `'@tevm/errors' does not provide an export named 'NoSignerAvailableError'` on import. That was inherited by anyone installing this package. `yarn add @deployoor/testing` now works with no `resolutions` block. The test suite also got a lot faster: deployoor's own 255-test suite went from ~7.6s to ~1.7s. **Breaking changes:** * `clients.tevm` is now `clients.provider`, an EIP-1193 handle. Every `hardhat_*` / `evm_*` method is reachable through `provider.request(...)`. * `cheatcodes.dumpState()` / `loadState(state)` are now `cheatcodes.snapshot()` / `revert(id)`. `revert` consumes its id, so restoring the same point twice needs two snapshots β€” `createFixture` handles this for you. * `cheatcodes.deal` is removed. Its ERC20 path required guessing which storage slot holds the balance mapping, which breaks on proxies, packed slots and rebasing tokens. Use `setBalance` for native ETH; mint or transfer for tokens, or reach for `hardhat_setStorageAt` through `provider` where you know your token's layout. * Options are no longer a tevm passthrough: `{ fork: { url, blockNumber, cacheDir }, chainId, blockGasLimit, autoMine }`. `fork` takes a URL string, not a viem transport. * The in-memory chain is named `edr-devnet` instead of `tevm-devnet`, which changes the key of seeded in-memory records. * **Requires Node β‰₯ 22**, which is what EDR declares. ## @deployoor/routescan@0.1.1 (2026-08-18) ### Patch Changes * f444c4e: Ship the built package. `0.1.0` went out without `dist/` β€” LICENSE, README, and `package.json` only β€” because a brand-new scoped package cannot be created over OIDC trusted publishing, and the one-shot bootstrap tarball was packed before the build. npm will not overwrite `0.1.0`. Install `0.1.1`. ## @deployoor/blockscout@0.1.1 (2026-08-18) ### Patch Changes * f444c4e: Ship the built package. `0.1.0` went out without `dist/` β€” LICENSE, README, and `package.json` only β€” because a brand-new scoped package cannot be created over OIDC trusted publishing, and the one-shot bootstrap tarball was packed before the build. npm will not overwrite `0.1.0`. Install `0.1.1`. ## @deployoor/etherscan@0.3.2 (2026-08-15) ### Patch Changes * 74e27c4: Correct the README's config example and prose: `apiUrl` overrides to any Etherscan-compatible endpoint, and Blockscout/Routescan now have dedicated plugins (`@deployoor/blockscout`, `@deployoor/routescan`) that handle their per-instance and mainnet/testnet quirks rather than being pointed at through `apiUrl`. ## @deployoor/etherscan@0.3.1 (2026-08-12) ### Patch Changes * 6de8a8a: Fix two bugs that made live Etherscan verification fail β€” both found by a real Sepolia deploy Neither was reachable from a mock `fetch`, and both were shipped. **`chainid` was only in the POST body.** Etherscan V2 requires it as a query parameter, and rejects a body-only value: ```text Missing or unsupported chainid parameter (required for v2 api) ``` So *every* live verification failed at submit β€” deploy-time and `deployoor verify` alike. The status poll already put `chainid` on the URL, which is why only the submit broke. It is now on the URL for both, and kept in the body too, since Blockscout/Routescan endpoints reached via `apiUrl` read it from there. The test suite asserted every form field of that request and nothing about its URL, so a body-only `chainid` passed. It is asserted now. **Deploy-time verification lost a race with Etherscan's indexer.** Submitting straight after the receipt gets: ```text Unable to locate ContractCode at 0x… ``` The chain is simply ahead of the explorer. That was a hard failure, so a fresh deploy's verification usually failed and had to be recovered with `deployoor verify` afterwards. The submit is now re-tried on that specific reply (bounded by `maxPolls`, the same budget the status poll uses), which is what hardhat-verify does. Observed live: six retries over about twelve seconds, then verified in the same run. `maxPolls` is also validated as a positive integer now. It bounds both recursions, so `0` skipped the status poll and reported a timeout on a verification that may have passed, and `NaN` β€” what `Number()` of an unset env var gives β€” failed every comparison and made the first attempt the last. Verified end to end on Sepolia β€” `Counter`, `Greeter` and `Vault` deployed with a Privy server wallet and confirmed verified through Etherscan's own `getsourcecode`. ## @deployoor/sourcify@0.3.0 (2026-08-11) ### Minor Changes * 5655eb2: Implement the new `onVerify` hook, so `deployoor verify` can verify recorded deployments after the fact β€” from the sources pinned beside each record, with no recompile. The submit-and-poll body is now one function that both hooks call, so deploy-time verification and after-the-fact verification cannot drift: they send the same standard-json request and read the same replies. `onContractDeployed` behaves exactly as before, including skipping when a deploy offers no compiler input. The `deployoor` peer range rises to `>=0.7.0 <1.0.0`, since `onVerify` and `VerifyContext` are what these now compile against. ## @deployoor/slack@0.3.0 (2026-08-11) ### Minor Changes * b81ed1e: Reject an empty `apiKey` / `webhook` when verification or notification starts, and stop the docs asserting the environment variable is set The examples everywhere showed `etherscan({ apiKey: process.env.ETHERSCAN_KEY! })`. The `!` only silences the type β€” at runtime an unset variable made `apikey: undefined` part of the request, so the failure arrived from the explorer as an authentication error at the end of a deploy, naming nothing you could act on. `@deployoor/slack` had the same shape with `webhook`. Both now check when the credential is actually needed β€” a verification starting, a notification about to be sent: ```text @deployoor/etherscan: apiKey is required and was empty. Etherscan V2 needs one key for every chain β€” set it in your environment (e.g. ETHERSCAN_KEY) and pass it as `etherscan({ apiKey: process.env.ETHERSCAN_KEY })`. ``` At first use rather than at construction, deliberately: `deployoor.config.ts` is imported by *every* command, so a construction-time throw made `deployoor generate` exit with an Etherscan credential error over a key it never uses. That is worse than the problem being fixed, since working locally without an explorer key is the normal case. The check still runs before the first request, so the failure stays local and still names the variable. `EtherscanOptions.apiKey` and `SlackOptions.webhook` are typed `string | undefined` so the env-var expression reads through without an assertion. Both keys stay **required**, so `etherscan({})` is still a compile error β€” the only thing now permitted is passing a value that might be missing, which is exactly the case the runtime check exists for. Also unifies the Slack environment variable across the docs: two examples and one README said `SLACK_HOOK` while the plugin's own docblock, the package README and the new error message all say `SLACK_WEBHOOK`. ## @deployoor/etherscan@0.3.0 (2026-08-11) ### Minor Changes * b81ed1e: Reject an empty `apiKey` / `webhook` when verification or notification starts, and stop the docs asserting the environment variable is set The examples everywhere showed `etherscan({ apiKey: process.env.ETHERSCAN_KEY! })`. The `!` only silences the type β€” at runtime an unset variable made `apikey: undefined` part of the request, so the failure arrived from the explorer as an authentication error at the end of a deploy, naming nothing you could act on. `@deployoor/slack` had the same shape with `webhook`. Both now check when the credential is actually needed β€” a verification starting, a notification about to be sent: ```text @deployoor/etherscan: apiKey is required and was empty. Etherscan V2 needs one key for every chain β€” set it in your environment (e.g. ETHERSCAN_KEY) and pass it as `etherscan({ apiKey: process.env.ETHERSCAN_KEY })`. ``` At first use rather than at construction, deliberately: `deployoor.config.ts` is imported by *every* command, so a construction-time throw made `deployoor generate` exit with an Etherscan credential error over a key it never uses. That is worse than the problem being fixed, since working locally without an explorer key is the normal case. The check still runs before the first request, so the failure stays local and still names the variable. `EtherscanOptions.apiKey` and `SlackOptions.webhook` are typed `string | undefined` so the env-var expression reads through without an assertion. Both keys stay **required**, so `etherscan({})` is still a compile error β€” the only thing now permitted is passing a value that might be missing, which is exactly the case the runtime check exists for. Also unifies the Slack environment variable across the docs: two examples and one README said `SLACK_HOOK` while the plugin's own docblock, the package README and the new error message all say `SLACK_WEBHOOK`. * 5655eb2: Implement the new `onVerify` hook, so `deployoor verify` can verify recorded deployments after the fact β€” from the sources pinned beside each record, with no recompile. The submit-and-poll body is now one function that both hooks call, so deploy-time verification and after-the-fact verification cannot drift: they send the same standard-json request and read the same replies. `onContractDeployed` behaves exactly as before, including skipping when a deploy offers no compiler input. The `deployoor` peer range rises to `>=0.7.0 <1.0.0`, since `onVerify` and `VerifyContext` are what these now compile against. ## @deployoor/wagmi@0.3.0 (2026-07-30) ### Minor Changes * 1cb8250: Use viem's `Hex`/`Address` types, cap peer ranges below the next major, and remove the deprecated `force` option. **BREAKING (pre-1.0)** * The `force` option is **gone** (it was deprecated one release ago and never shipped in that state): replace `force: true` with `redeploymentStrategy: 'always'` and `force: false` with `redeploymentStrategy: 'never'`. * `deployoor`'s zod validators are renamed to free the bare names for viem's types: `Hex` β†’ `HexSchema`, `Address` β†’ `AddressSchema`, `Bytecode` β†’ `BytecodeSchema` (matching the existing `AbiSchema`). The exported record/artifact **types** are unchanged β€” they now spell their hex fields as viem's `Hex` and `Address`, which are structurally identical to the `` `0x${string}` `` they replace, so consumers need no change. **Peer ranges** viem 3 is imminent and `>=2` would have accepted it silently. Every `viem` peer is now `^2`; `@wagmi/cli` is `^2`, `@tevm/compiler` is capped `<2`, and `solc` `<0.9`. The plugin packages' `deployoor` peer was `*` β€” accepting any engine version, including breaking pre-1.0 minors β€” and is now `>=0.5.0 <1.0.0`. `@deployoor/testing` and `fhevm-tevm-mocks` additionally declared `viem >=2.49`, a floor their own catalog version (2.44.2) did not satisfy and that nothing required (tevm itself asks for `^2.37.9`). `@deployoor/wagmi` now declares the `viem` peer it always implicitly relied on. **Internal** * One `ensureHexPrefix` helper in `artifacts/parse.ts` replaces two copies of the "0x-prefix raw solc output" logic (`parse.ts` and `artifacts/tevm.ts`). It is deliberately not viem's `toHex`, which *encodes* a string rather than prefixing it, and cannot assert `isHex` because unlinked bytecode legitimately contains `__$…$__` placeholders. * Fixed-width hex fields are built with `numberToHex(n, { size })` / `concatHex` / byte-indexed `slice` instead of `.toString(16).padStart(...)` and character arithmetic. * No `let`, `var`, or reassignment anywhere in the codebase, enforced by oxlint (`no-var`, `prefer-const`, `no-const-assign`, `no-param-reassign`, `no-plusplus`). The accumulator in `codegen/generate.ts` is a `.flatMap`, the tevm fully-qualified-name dedupe is a `.reduce`, and the deploy tests get their EVM clients from a top-level `await` rather than a `let` filled in by `beforeAll`. * Dropped a duplicate `tevm` devDependency in `fhevm-tevm-mocks` (already a `dependencies` entry at the same version). ## @deployoor/testing@0.3.1 (2026-07-30) ### Patch Changes * 1cb8250: Use viem's `Hex`/`Address` types, cap peer ranges below the next major, and remove the deprecated `force` option. **BREAKING (pre-1.0)** * The `force` option is **gone** (it was deprecated one release ago and never shipped in that state): replace `force: true` with `redeploymentStrategy: 'always'` and `force: false` with `redeploymentStrategy: 'never'`. * `deployoor`'s zod validators are renamed to free the bare names for viem's types: `Hex` β†’ `HexSchema`, `Address` β†’ `AddressSchema`, `Bytecode` β†’ `BytecodeSchema` (matching the existing `AbiSchema`). The exported record/artifact **types** are unchanged β€” they now spell their hex fields as viem's `Hex` and `Address`, which are structurally identical to the `` `0x${string}` `` they replace, so consumers need no change. **Peer ranges** viem 3 is imminent and `>=2` would have accepted it silently. Every `viem` peer is now `^2`; `@wagmi/cli` is `^2`, `@tevm/compiler` is capped `<2`, and `solc` `<0.9`. The plugin packages' `deployoor` peer was `*` β€” accepting any engine version, including breaking pre-1.0 minors β€” and is now `>=0.5.0 <1.0.0`. `@deployoor/testing` and `fhevm-tevm-mocks` additionally declared `viem >=2.49`, a floor their own catalog version (2.44.2) did not satisfy and that nothing required (tevm itself asks for `^2.37.9`). `@deployoor/wagmi` now declares the `viem` peer it always implicitly relied on. **Internal** * One `ensureHexPrefix` helper in `artifacts/parse.ts` replaces two copies of the "0x-prefix raw solc output" logic (`parse.ts` and `artifacts/tevm.ts`). It is deliberately not viem's `toHex`, which *encodes* a string rather than prefixing it, and cannot assert `isHex` because unlinked bytecode legitimately contains `__$…$__` placeholders. * Fixed-width hex fields are built with `numberToHex(n, { size })` / `concatHex` / byte-indexed `slice` instead of `.toString(16).padStart(...)` and character arithmetic. * No `let`, `var`, or reassignment anywhere in the codebase, enforced by oxlint (`no-var`, `prefer-const`, `no-const-assign`, `no-param-reassign`, `no-plusplus`). The accumulator in `codegen/generate.ts` is a `.flatMap`, the tevm fully-qualified-name dedupe is a `.reduce`, and the deploy tests get their EVM clients from a top-level `await` rather than a `let` filled in by `beforeAll`. * Dropped a duplicate `tevm` devDependency in `fhevm-tevm-mocks` (already a `dependencies` entry at the same version). ## @deployoor/sourcify@0.2.2 (2026-07-30) ### Patch Changes * 1cb8250: Use viem's `Hex`/`Address` types, cap peer ranges below the next major, and remove the deprecated `force` option. **BREAKING (pre-1.0)** * The `force` option is **gone** (it was deprecated one release ago and never shipped in that state): replace `force: true` with `redeploymentStrategy: 'always'` and `force: false` with `redeploymentStrategy: 'never'`. * `deployoor`'s zod validators are renamed to free the bare names for viem's types: `Hex` β†’ `HexSchema`, `Address` β†’ `AddressSchema`, `Bytecode` β†’ `BytecodeSchema` (matching the existing `AbiSchema`). The exported record/artifact **types** are unchanged β€” they now spell their hex fields as viem's `Hex` and `Address`, which are structurally identical to the `` `0x${string}` `` they replace, so consumers need no change. **Peer ranges** viem 3 is imminent and `>=2` would have accepted it silently. Every `viem` peer is now `^2`; `@wagmi/cli` is `^2`, `@tevm/compiler` is capped `<2`, and `solc` `<0.9`. The plugin packages' `deployoor` peer was `*` β€” accepting any engine version, including breaking pre-1.0 minors β€” and is now `>=0.5.0 <1.0.0`. `@deployoor/testing` and `fhevm-tevm-mocks` additionally declared `viem >=2.49`, a floor their own catalog version (2.44.2) did not satisfy and that nothing required (tevm itself asks for `^2.37.9`). `@deployoor/wagmi` now declares the `viem` peer it always implicitly relied on. **Internal** * One `ensureHexPrefix` helper in `artifacts/parse.ts` replaces two copies of the "0x-prefix raw solc output" logic (`parse.ts` and `artifacts/tevm.ts`). It is deliberately not viem's `toHex`, which *encodes* a string rather than prefixing it, and cannot assert `isHex` because unlinked bytecode legitimately contains `__$…$__` placeholders. * Fixed-width hex fields are built with `numberToHex(n, { size })` / `concatHex` / byte-indexed `slice` instead of `.toString(16).padStart(...)` and character arithmetic. * No `let`, `var`, or reassignment anywhere in the codebase, enforced by oxlint (`no-var`, `prefer-const`, `no-const-assign`, `no-param-reassign`, `no-plusplus`). The accumulator in `codegen/generate.ts` is a `.flatMap`, the tevm fully-qualified-name dedupe is a `.reduce`, and the deploy tests get their EVM clients from a top-level `await` rather than a `let` filled in by `beforeAll`. * Dropped a duplicate `tevm` devDependency in `fhevm-tevm-mocks` (already a `dependencies` entry at the same version). ## @deployoor/slack@0.2.1 (2026-07-30) ### Patch Changes * 1cb8250: Use viem's `Hex`/`Address` types, cap peer ranges below the next major, and remove the deprecated `force` option. **BREAKING (pre-1.0)** * The `force` option is **gone** (it was deprecated one release ago and never shipped in that state): replace `force: true` with `redeploymentStrategy: 'always'` and `force: false` with `redeploymentStrategy: 'never'`. * `deployoor`'s zod validators are renamed to free the bare names for viem's types: `Hex` β†’ `HexSchema`, `Address` β†’ `AddressSchema`, `Bytecode` β†’ `BytecodeSchema` (matching the existing `AbiSchema`). The exported record/artifact **types** are unchanged β€” they now spell their hex fields as viem's `Hex` and `Address`, which are structurally identical to the `` `0x${string}` `` they replace, so consumers need no change. **Peer ranges** viem 3 is imminent and `>=2` would have accepted it silently. Every `viem` peer is now `^2`; `@wagmi/cli` is `^2`, `@tevm/compiler` is capped `<2`, and `solc` `<0.9`. The plugin packages' `deployoor` peer was `*` β€” accepting any engine version, including breaking pre-1.0 minors β€” and is now `>=0.5.0 <1.0.0`. `@deployoor/testing` and `fhevm-tevm-mocks` additionally declared `viem >=2.49`, a floor their own catalog version (2.44.2) did not satisfy and that nothing required (tevm itself asks for `^2.37.9`). `@deployoor/wagmi` now declares the `viem` peer it always implicitly relied on. **Internal** * One `ensureHexPrefix` helper in `artifacts/parse.ts` replaces two copies of the "0x-prefix raw solc output" logic (`parse.ts` and `artifacts/tevm.ts`). It is deliberately not viem's `toHex`, which *encodes* a string rather than prefixing it, and cannot assert `isHex` because unlinked bytecode legitimately contains `__$…$__` placeholders. * Fixed-width hex fields are built with `numberToHex(n, { size })` / `concatHex` / byte-indexed `slice` instead of `.toString(16).padStart(...)` and character arithmetic. * No `let`, `var`, or reassignment anywhere in the codebase, enforced by oxlint (`no-var`, `prefer-const`, `no-const-assign`, `no-param-reassign`, `no-plusplus`). The accumulator in `codegen/generate.ts` is a `.flatMap`, the tevm fully-qualified-name dedupe is a `.reduce`, and the deploy tests get their EVM clients from a top-level `await` rather than a `let` filled in by `beforeAll`. * Dropped a duplicate `tevm` devDependency in `fhevm-tevm-mocks` (already a `dependencies` entry at the same version). ## @deployoor/hardhat@0.3.1 (2026-07-30) ### Patch Changes * 1cb8250: Use viem's `Hex`/`Address` types, cap peer ranges below the next major, and remove the deprecated `force` option. **BREAKING (pre-1.0)** * The `force` option is **gone** (it was deprecated one release ago and never shipped in that state): replace `force: true` with `redeploymentStrategy: 'always'` and `force: false` with `redeploymentStrategy: 'never'`. * `deployoor`'s zod validators are renamed to free the bare names for viem's types: `Hex` β†’ `HexSchema`, `Address` β†’ `AddressSchema`, `Bytecode` β†’ `BytecodeSchema` (matching the existing `AbiSchema`). The exported record/artifact **types** are unchanged β€” they now spell their hex fields as viem's `Hex` and `Address`, which are structurally identical to the `` `0x${string}` `` they replace, so consumers need no change. **Peer ranges** viem 3 is imminent and `>=2` would have accepted it silently. Every `viem` peer is now `^2`; `@wagmi/cli` is `^2`, `@tevm/compiler` is capped `<2`, and `solc` `<0.9`. The plugin packages' `deployoor` peer was `*` β€” accepting any engine version, including breaking pre-1.0 minors β€” and is now `>=0.5.0 <1.0.0`. `@deployoor/testing` and `fhevm-tevm-mocks` additionally declared `viem >=2.49`, a floor their own catalog version (2.44.2) did not satisfy and that nothing required (tevm itself asks for `^2.37.9`). `@deployoor/wagmi` now declares the `viem` peer it always implicitly relied on. **Internal** * One `ensureHexPrefix` helper in `artifacts/parse.ts` replaces two copies of the "0x-prefix raw solc output" logic (`parse.ts` and `artifacts/tevm.ts`). It is deliberately not viem's `toHex`, which *encodes* a string rather than prefixing it, and cannot assert `isHex` because unlinked bytecode legitimately contains `__$…$__` placeholders. * Fixed-width hex fields are built with `numberToHex(n, { size })` / `concatHex` / byte-indexed `slice` instead of `.toString(16).padStart(...)` and character arithmetic. * No `let`, `var`, or reassignment anywhere in the codebase, enforced by oxlint (`no-var`, `prefer-const`, `no-const-assign`, `no-param-reassign`, `no-plusplus`). The accumulator in `codegen/generate.ts` is a `.flatMap`, the tevm fully-qualified-name dedupe is a `.reduce`, and the deploy tests get their EVM clients from a top-level `await` rather than a `let` filled in by `beforeAll`. * Dropped a duplicate `tevm` devDependency in `fhevm-tevm-mocks` (already a `dependencies` entry at the same version). ## @deployoor/etherscan@0.2.1 (2026-07-30) ### Patch Changes * 1cb8250: Use viem's `Hex`/`Address` types, cap peer ranges below the next major, and remove the deprecated `force` option. **BREAKING (pre-1.0)** * The `force` option is **gone** (it was deprecated one release ago and never shipped in that state): replace `force: true` with `redeploymentStrategy: 'always'` and `force: false` with `redeploymentStrategy: 'never'`. * `deployoor`'s zod validators are renamed to free the bare names for viem's types: `Hex` β†’ `HexSchema`, `Address` β†’ `AddressSchema`, `Bytecode` β†’ `BytecodeSchema` (matching the existing `AbiSchema`). The exported record/artifact **types** are unchanged β€” they now spell their hex fields as viem's `Hex` and `Address`, which are structurally identical to the `` `0x${string}` `` they replace, so consumers need no change. **Peer ranges** viem 3 is imminent and `>=2` would have accepted it silently. Every `viem` peer is now `^2`; `@wagmi/cli` is `^2`, `@tevm/compiler` is capped `<2`, and `solc` `<0.9`. The plugin packages' `deployoor` peer was `*` β€” accepting any engine version, including breaking pre-1.0 minors β€” and is now `>=0.5.0 <1.0.0`. `@deployoor/testing` and `fhevm-tevm-mocks` additionally declared `viem >=2.49`, a floor their own catalog version (2.44.2) did not satisfy and that nothing required (tevm itself asks for `^2.37.9`). `@deployoor/wagmi` now declares the `viem` peer it always implicitly relied on. **Internal** * One `ensureHexPrefix` helper in `artifacts/parse.ts` replaces two copies of the "0x-prefix raw solc output" logic (`parse.ts` and `artifacts/tevm.ts`). It is deliberately not viem's `toHex`, which *encodes* a string rather than prefixing it, and cannot assert `isHex` because unlinked bytecode legitimately contains `__$…$__` placeholders. * Fixed-width hex fields are built with `numberToHex(n, { size })` / `concatHex` / byte-indexed `slice` instead of `.toString(16).padStart(...)` and character arithmetic. * No `let`, `var`, or reassignment anywhere in the codebase, enforced by oxlint (`no-var`, `prefer-const`, `no-const-assign`, `no-param-reassign`, `no-plusplus`). The accumulator in `codegen/generate.ts` is a `.flatMap`, the tevm fully-qualified-name dedupe is a `.reduce`, and the deploy tests get their EVM clients from a top-level `await` rather than a `let` filled in by `beforeAll`. * Dropped a duplicate `tevm` devDependency in `fhevm-tevm-mocks` (already a `dependencies` entry at the same version). ## @deployoor/sourcify@0.2.1 (2026-07-09) ### Patch Changes * fc1fe7f: Treat Sourcify v2 already-verified contracts as success. Real Sourcify v2 does not return HTTP 409 for an already-verified contract β€” it accepts the job (202) and then completes it carrying `error.customCode: "already_verified"`. The verifier now recognizes that code and resolves (matching the etherscan plugin) instead of throwing, so idempotent re-deploys of an already-verified contract no longer fail verification. ## @deployoor/hardhat@0.3.0 (2026-07-09) ### Minor Changes * bff24ce: Add Hardhat 3 support via a new `@deployoor/hardhat/v3` entry point. Hardhat 3 replaced Hardhat 2's side-effect plugin registration with a declarative, ESM-only model, so the two majors need different wiring. This package now ships both from one install: * `@deployoor/hardhat` (default) β€” Hardhat 2, registered by `import "@deployoor/hardhat"` (unchanged). * `@deployoor/hardhat/v3` β€” Hardhat 3, a plugin object you add to `plugins: []`. It overrides the `compile` task to run `deployoor generate` afterward (via a lazily-imported action, as Hardhat 3 requires for plugins). Disable it by removing it from `plugins`. Both reuse the same `generateDeployers` and the shared "never break compile" behavior. The peer range widens to `hardhat@^2 || ^3`. ## @deployoor/wagmi@0.2.0 (2026-07-03) ### Minor Changes * 7913ff9: Point repository metadata at `raycashxyz/deployoor` after transferring the GitHub org. ## @deployoor/testing@0.3.0 (2026-07-03) ### Minor Changes * 7913ff9: Point repository metadata at `raycashxyz/deployoor` after transferring the GitHub org. ## @deployoor/sourcify@0.2.0 (2026-07-03) ### Minor Changes * 7913ff9: Point repository metadata at `raycashxyz/deployoor` after transferring the GitHub org. ## @deployoor/slack@0.2.0 (2026-07-03) ### Minor Changes * 7913ff9: Point repository metadata at `raycashxyz/deployoor` after transferring the GitHub org. ## @deployoor/hardhat@0.2.0 (2026-07-03) ### Minor Changes * 7913ff9: Point repository metadata at `raycashxyz/deployoor` after transferring the GitHub org. ## @deployoor/etherscan@0.2.0 (2026-07-03) ### Minor Changes * 7913ff9: Point repository metadata at `raycashxyz/deployoor` after transferring the GitHub org. ## @deployoor/wagmi@0.1.1 (2026-07-02) ### Patch Changes * 4e505d0: Compat hardening from packaging/resolution audit: `sideEffects: false` on all publishable packages; `typesVersions` on `deployoor/plugin` and `deployoor/generate` for legacy `moduleResolution: "node"`; Node `>=20` engines on tevm-dependent packages; align tevm as a hard dependency and declare `viem >=2.49` where tevm requires it; document TypeScript-first codegen and CJS/ESM caveats; add a Windows CI smoke job. * 2a34f70: Harden deployment records and test helpers: records now carry `schemaVersion: 1`, use chain-id-based filesystem keys, guard chainId mismatches on reuse, warn on stale bytecode/constructor args, and write filesystem records atomically under a lock. `@deployoor/testing` now exposes tevm, cheatcodes, fixtures, and deployment-record seeding (seeded records are remapped onto the in-memory chain so `getOrDeploy` reuses them). Verifier plugins can retry reused deployments when artifact metadata is available, Slack can notify failed deploys, and the wagmi plugin uses Zod 4-safe validators with ABI drift checks. **BREAKING (pre-1.0):** the record folder layout changed from `deployments//` to `deployments/-/` (e.g. `deployments/sepolia/` β†’ `deployments/11155111-sepolia/`), and the record's `networkName` field now holds that composite key. Records written by 0.1.x are not read from the old location β€” move each folder to its new name (and update `networkName` inside the files, or let the next deploy rewrite them) before re-running deploy scripts, or `getOrDeploy` will redeploy. ## @deployoor/testing@0.2.2 (2026-07-02) ### Patch Changes * 7814c6d: Declare `zod` as a dependency. `@deployoor/testing` reaches zod v4 APIs (`treeifyError`) when validating deployment records, but did not declare zod β€” so under a hoisted node-linker a consumer's zod v3 could shadow it and crash at import ("does not provide an export named 'treeifyError'"). Pinned to `^4.4.3`, matching the other deployoor packages. ## @deployoor/testing@0.2.1 (2026-07-02) ### Patch Changes * 5b411a5: Docs: include peer deps (`deployoor`, `viem`) in the `@deployoor/testing` install command, and fix the broken relative `deployoor` link in the `@deployoor/hardhat` README so it resolves on the npm package page. ## @deployoor/sourcify@0.1.1 (2026-07-02) ### Patch Changes * 4e505d0: Compat hardening from packaging/resolution audit: `sideEffects: false` on all publishable packages; `typesVersions` on `deployoor/plugin` and `deployoor/generate` for legacy `moduleResolution: "node"`; Node `>=20` engines on tevm-dependent packages; align tevm as a hard dependency and declare `viem >=2.49` where tevm requires it; document TypeScript-first codegen and CJS/ESM caveats; add a Windows CI smoke job. * 2a34f70: Harden deployment records and test helpers: records now carry `schemaVersion: 1`, use chain-id-based filesystem keys, guard chainId mismatches on reuse, warn on stale bytecode/constructor args, and write filesystem records atomically under a lock. `@deployoor/testing` now exposes tevm, cheatcodes, fixtures, and deployment-record seeding (seeded records are remapped onto the in-memory chain so `getOrDeploy` reuses them). Verifier plugins can retry reused deployments when artifact metadata is available, Slack can notify failed deploys, and the wagmi plugin uses Zod 4-safe validators with ABI drift checks. **BREAKING (pre-1.0):** the record folder layout changed from `deployments//` to `deployments/-/` (e.g. `deployments/sepolia/` β†’ `deployments/11155111-sepolia/`), and the record's `networkName` field now holds that composite key. Records written by 0.1.x are not read from the old location β€” move each folder to its new name (and update `networkName` inside the files, or let the next deploy rewrite them) before re-running deploy scripts, or `getOrDeploy` will redeploy. ## @deployoor/slack@0.1.1 (2026-07-02) ### Patch Changes * 4e505d0: Compat hardening from packaging/resolution audit: `sideEffects: false` on all publishable packages; `typesVersions` on `deployoor/plugin` and `deployoor/generate` for legacy `moduleResolution: "node"`; Node `>=20` engines on tevm-dependent packages; align tevm as a hard dependency and declare `viem >=2.49` where tevm requires it; document TypeScript-first codegen and CJS/ESM caveats; add a Windows CI smoke job. * 2a34f70: Harden deployment records and test helpers: records now carry `schemaVersion: 1`, use chain-id-based filesystem keys, guard chainId mismatches on reuse, warn on stale bytecode/constructor args, and write filesystem records atomically under a lock. `@deployoor/testing` now exposes tevm, cheatcodes, fixtures, and deployment-record seeding (seeded records are remapped onto the in-memory chain so `getOrDeploy` reuses them). Verifier plugins can retry reused deployments when artifact metadata is available, Slack can notify failed deploys, and the wagmi plugin uses Zod 4-safe validators with ABI drift checks. **BREAKING (pre-1.0):** the record folder layout changed from `deployments//` to `deployments/-/` (e.g. `deployments/sepolia/` β†’ `deployments/11155111-sepolia/`), and the record's `networkName` field now holds that composite key. Records written by 0.1.x are not read from the old location β€” move each folder to its new name (and update `networkName` inside the files, or let the next deploy rewrite them) before re-running deploy scripts, or `getOrDeploy` will redeploy. ## @deployoor/hardhat@0.1.1 (2026-07-02) ### Patch Changes * 5b411a5: Docs: include peer deps (`deployoor`, `viem`) in the `@deployoor/testing` install command, and fix the broken relative `deployoor` link in the `@deployoor/hardhat` README so it resolves on the npm package page. ## @deployoor/etherscan@0.1.1 (2026-07-02) ### Patch Changes * 4e505d0: Compat hardening from packaging/resolution audit: `sideEffects: false` on all publishable packages; `typesVersions` on `deployoor/plugin` and `deployoor/generate` for legacy `moduleResolution: "node"`; Node `>=20` engines on tevm-dependent packages; align tevm as a hard dependency and declare `viem >=2.49` where tevm requires it; document TypeScript-first codegen and CJS/ESM caveats; add a Windows CI smoke job. * 2a34f70: Harden deployment records and test helpers: records now carry `schemaVersion: 1`, use chain-id-based filesystem keys, guard chainId mismatches on reuse, warn on stale bytecode/constructor args, and write filesystem records atomically under a lock. `@deployoor/testing` now exposes tevm, cheatcodes, fixtures, and deployment-record seeding (seeded records are remapped onto the in-memory chain so `getOrDeploy` reuses them). Verifier plugins can retry reused deployments when artifact metadata is available, Slack can notify failed deploys, and the wagmi plugin uses Zod 4-safe validators with ABI drift checks. **BREAKING (pre-1.0):** the record folder layout changed from `deployments//` to `deployments/-/` (e.g. `deployments/sepolia/` β†’ `deployments/11155111-sepolia/`), and the record's `networkName` field now holds that composite key. Records written by 0.1.x are not read from the old location β€” move each folder to its new name (and update `networkName` inside the files, or let the next deploy rewrite them) before re-running deploy scripts, or `getOrDeploy` will redeploy. ## @deployoor/wagmi@0.1.0 (2026-06-30) ### Minor Changes * 15fab66: Initial public release of deployoor: viem-first contract deployment with a `deployments/` source of truth, idempotent `getOrDeploy` deployers, the `deployoor/plugin` lifecycle SDK, and the `@deployoor/wagmi` / `@deployoor/etherscan` / `@deployoor/sourcify` / `@deployoor/slack` plugins. ## @deployoor/sourcify@0.1.0 (2026-06-30) ### Minor Changes * 15fab66: Initial public release of deployoor: viem-first contract deployment with a `deployments/` source of truth, idempotent `getOrDeploy` deployers, the `deployoor/plugin` lifecycle SDK, and the `@deployoor/wagmi` / `@deployoor/etherscan` / `@deployoor/sourcify` / `@deployoor/slack` plugins. ## @deployoor/slack@0.1.0 (2026-06-30) ### Minor Changes * 15fab66: Initial public release of deployoor: viem-first contract deployment with a `deployments/` source of truth, idempotent `getOrDeploy` deployers, the `deployoor/plugin` lifecycle SDK, and the `@deployoor/wagmi` / `@deployoor/etherscan` / `@deployoor/sourcify` / `@deployoor/slack` plugins. ## @deployoor/etherscan@0.1.0 (2026-06-30) ### Minor Changes * 15fab66: Initial public release of deployoor: viem-first contract deployment with a `deployments/` source of truth, idempotent `getOrDeploy` deployers, the `deployoor/plugin` lifecycle SDK, and the `@deployoor/wagmi` / `@deployoor/etherscan` / `@deployoor/sourcify` / `@deployoor/slack` plugins. # Contact deployoor is maintained in the open, so most of the channels below are public and all of them reach the same people. There is no support queue and no ticket system: an issue on the repository is the fastest way to get a fix. For anything that does not belong in public, email **[hi@raycash.xyz](mailto\:hi@raycash.xyz)**. ## Report a bug or request a feature Open an issue at [github.com/raycashxyz/deployoor/issues](https://github.com/raycashxyz/deployoor/issues). A report moves fastest when it carries: * the versions of `deployoor`, `viem`, and Node you are running (`npx deployoor --version`, `node --version`) * the framework the artifacts come from, so Hardhat v2, Hardhat v3, or Foundry * the command you ran and the full error, including the tagged error name (for example `ContractArtifactNotFound`) * the relevant part of `deployoor.config.ts`, and the deployment record under `deployments/` if the problem is a redeploy or a reuse you did not expect ## Ask a question * **[GitHub Discussions](https://github.com/raycashxyz/deployoor/discussions)** for anything that is not a bug: "is this the right tool for X", design questions, or a pattern you are unsure about. * **[Telegram group](https://t.me/deployoor)** for shorter back-and-forth with other people using it. * **[hi@raycash.xyz](mailto\:hi@raycash.xyz)** for everything else: partnerships, sponsorship, press, or a question you would rather not ask in public. Before any of them, [llms-full.txt](https://deployoor.dev/llms-full.txt) is the entire documentation as one file, which is often quicker to search than asking. ## Contribute Pull requests are welcome. [CONTRIBUTING.md](https://github.com/raycashxyz/deployoor/blob/main/CONTRIBUTING.md) covers the setup, the conventions the codebase holds to, and the checks that run in CI. If the change is more than a fix, open an issue first so the design gets settled before anyone writes code. ## Security If you find a vulnerability, please do not describe it in a public issue. Two private channels, either is fine: * **[Report it on GitHub](https://github.com/raycashxyz/deployoor/security/advisories/new)**, which opens a private advisory only the maintainers can read, and turns into a published advisory and a CVE once there is a fix. * **Email [hi@raycash.xyz](mailto\:hi@raycash.xyz)** if you would rather not use GitHub. deployoor never handles your private keys (it signs through the viem wallet client you pass in), so the reports that matter most are about the deployment record, the generated deployers, or the verification path. ## Who you are reaching deployoor is published by [Raycash](https://www.raycash.xyz), and maintained by Valerio Leo. See the [about page](/about) for what that means for the license and the roadmap. # Getting started Install deployoor, run through the quickstart, then explore the guides in the sidebar. # Privacy **Last updated: 27 August 2026.** deployoor has no accounts, no analytics, and no telemetry. This page says exactly what that means for the documentation site and for the tool you install. ## This site deployoor.dev is a static documentation site built with [Vocs](https://vocs.dev) and hosted on [Vercel](https://vercel.com). * **No analytics and no ad or tracking scripts.** Nothing on these pages profiles you, and no third-party script runs. Fonts, images, and the search index are served from this domain, so loading a page contacts no other host. * **No cookies are set by this site.** Two things are kept in your browser's own `localStorage` and never leave it: the light or dark theme you pick, and the last few terms you typed into the search box, so the dialog can offer them again. Clearing site data removes both. * **Search runs in your browser.** The search index is downloaded as a static file and queried locally, so your queries are never sent anywhere. * **Server logs.** Vercel processes ordinary request data (IP address, user agent, requested URL, timestamp) to serve the site, cache it, and defend it against abuse. That is Vercel's infrastructure logging under their own [privacy policy](https://vercel.com/legal/privacy-policy); it is not read, exported, or joined with anything else. * **No forms.** There is nothing on this site to submit, subscribe to, or sign up for, so there is nothing for us to store. The channels on the [contact page](/contact) are GitHub and Telegram, each governed by their own policy. * **Outbound links.** Following a link to GitHub, npm, Telegram, or a block explorer hands you to that service, which has its own terms. ## The tool you install `deployoor` and the `@deployoor/*` packages run locally, on your machine or in your CI. They report nothing back: * **No telemetry.** No version pings, no usage counters, no crash reporting. There is no endpoint for the CLI to call because none exists. * **The network calls are the ones you configure.** A deploy talks to the RPC endpoint of the viem client you pass in. A verifier plugin talks to the block explorer you enabled ([Etherscan](/guides/verify), Sourcify, Blockscout, or Routescan), which means your contract source and constructor arguments go to that explorer by design, because that is what verification is. The Slack plugin posts to the webhook you supply. Nothing else leaves your process. * **Keys stay yours.** deployoor never reads, stores, or transmits a private key. It asks for a viem wallet client and calls it to sign, so the key stays wherever you keep it, including a cloud signer that never exposes it at all. * **Deployment records are files on your disk.** Addresses, ABIs, chain IDs, constructor arguments, and transaction hashes are written to `deployments/` in your own repository. Everything in them is already public on-chain data. You decide what gets committed; see [version control](/concepts/version-control). ## Changes This page changes when the site or the tool does, and the date at the top moves with it. Every version of it is in the [repository's history](https://github.com/raycashxyz/deployoor/commits/main/apps/docs/src/pages/privacy.mdx). Questions about anything here belong on the [contact page](/contact).