deployoor

Deploy contracts from TypeScript.

The missing deploy primitive for viem: bring your own wallet and clients, call one function, get a typed contract back — on every chain you ship to.

Hardhat v2 & v3 · Foundry · plain Solidity (tevm)

Works with any viem-compatible wallet

also AWS KMS, Google Cloud KMS, Fireblocks, Ledger, or any EIP-1193 provider

Your project, with deployoor plugged in

From the project you already have to a typed, committed deployment.

  1. 01

    Start with the project you have: hardhat

    Contracts in contracts/, config in hardhat.config.js, and the artifacts/ that hardhat compile writes. Nothing here changes.

  2. 02

    Run one command: deployoor generate

    It scans your repo and finds your compiled artifacts by reading your Hardhat or Foundry config, then writes one typed deployer per contract. Wherever your build output goes, it looks there.

    # artifacts/ — the path comes from your hardhat.config
    npx hardhat compile
    
    # → deployers/, one getOrDeploy per contract
    npx deployoor generate
  3. 03

    You get a typed deployer per contract in deployers/

    Constructor arguments are typed from the abi, so args gets checked instead of being a loose array. A deployer holds a name and an abi and nothing else — bytecode and compiler settings are read from your artifacts when you deploy — so these are small enough to commit and read in a diff.

    // AUTO-GENERATED by deployoor. Do not edit by hand.
    import { defineDeployer } from "deployoor";
    import type { Config } from "deployoor";
    import { counterArtifact } from "./types/Counter";
    
    // counterArtifact is a name + abi. The bytecode is read from
    // artifacts/ at deploy time, so it is never copied in here.
    // Counter's constructor is (uint256), so args is [bigint].
    export const getOrDeployCounter = defineDeployer(
      counterArtifact,
      {} satisfies Config,
    );
  4. 04

    Deploy from a script in scripts/deploy.ts

    You pass in the viem clients you already have, so whatever signs for you elsewhere signs for your deploys: a local key, a KMS, an embedded wallet, a hardware wallet. deployoor reaches your wallet through viem alone and never sees a key.

    Works with any viem-compatible wallet

    Wallet recipes
    // The one import that matters: your generated deployer.
    import { getOrDeployCounter } from "../deployers";
    import { publicClient, walletClient } from "./clients";
    
    // Any viem clients. walletClient signs and needs a chain;
    // publicClient reads. Neither is a deployoor type.
    const { contract, freshDeploy } = await getOrDeployCounter({
      walletClient,
      publicClient,
      args: [7n], // typed from the constructor
    });
    
    // Say what happened: freshDeploy is true only when this
    // run actually broadcast a deploy transaction.
    console.log(freshDeploy ? "deployed" : "reused", contract.address);
    
    // contract is a viem contract: .read.* and .write.* work.
    console.log(await contract.read.number()); // 7n
  5. 05

    Run it like any other script: tsx scripts/deploy.ts

    There is no framework to boot and no task runner, because it is a Node program. The first run deploys and records it. Run it again and you get the same contract back, with no transaction.

    # First run: deploys, then writes the record.
    $ npx tsx scripts/deploy.ts
    deployed 0x5FbDB2315678afecb367f032d93F642f64180aa3
    7n
    
    # Second run: reads the record, sends nothing.
    $ npx tsx scripts/deploy.ts
    reused 0x5FbDB2315678afecb367f032d93F642f64180aa3
    7n
  6. 06

    All deployments are recorded in deployments/

    One JSON file per contract per chain — the permanent record of what is deployed where. It is how later runs find the contract, how your app talks to it, and how deployoor verify proves it on an explorer long after the deploy.

    Deployment records
    deployments/11155111-sepolia/Counter.json
    
    {
      "contractName": "Counter",
      "address": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
      "chainId": 11155111,
      "constructorArgs": ["7"],
      "transactionHash": "0x…",
      "sourcesHash": "0x8f3a…"
    }
  7. 07

    Your tests can run standalone too

    @deployoor/testing bundles an in-memory EVM (tevm) exposed as plain viem clients, so tests run with no Hardhat test environment and no node to start — under any runner: Vitest, Jest, node:test, bun test.

    VitestJest
    Testing guide
    import { createTestClients } from "@deployoor/testing";
    import { getOrDeployCounter } from "../deployers";
    
    it("deploys with its constructor args", async () => {
      // An in-memory EVM exposed as viem clients, plus an
      // in-memory store, so nothing touches deployments/.
      const { contract } = await getOrDeployCounter({
        ...(await createTestClients()),
        args: [7n],
      });
    
      // A real deploy on a real EVM, just an in-process one.
      expect(await contract.read.number()).toBe(7n);
    });
  8. 08

    Consume it anywhere, via the wagmi plugin

    Your frontend reads the same records your deploy script wrote, through @wagmi/cli. Nothing is copied by hand, so an address cannot go stale in one repo while it is correct in the other.

    wagmiviem
    Consume in your app
    import { defineConfig } from "@wagmi/cli";
    import { actions } from "@wagmi/cli/plugins";
    import { deployments } from "@deployoor/wagmi";
    
    export default defineConfig({
      out: "src/generated.ts",
      plugins: [
        // reads deployments/ — the address and abi come from there
        deployments({ path: "./deployments" }),
        // actions() for viem, react() for hooks
        actions(),
      ],
    });
  9. 09

    And when you want to change something, there's deployoor.config.ts

    Every option has good defaults, but if you need custom configs or want to extend functionality via plugins, the deployoor.config.ts file is your friend.

    Verify contracts
    import { defineConfig } from "deployoor";
    import { etherscan } from "@deployoor/etherscan";
    import { sourcify } from "@deployoor/sourcify";
    import { blockscout } from "@deployoor/blockscout";
    import { routescan } from "@deployoor/routescan";
    import { slack } from "@deployoor/slack";
    
    export default defineConfig({
      // folders, only if yours are not the defaults
      out: "./generated/deployers",
      deploymentsPath: "./records",
    
      plugins: [
        // one Etherscan key covers every chain it supports
        etherscan({ apiKey: process.env.ETHERSCAN_API_KEY }),
        // keyless, and not owned by any explorer
        sourcify(),
        // Blockscout runs per chain, so name the instance
        blockscout({ instanceUrl: "https://eth-sepolia.blockscout.com" }),
        // mainnet vs testnet is worked out from the chain id
        routescan(),
        // and tell the team it happened
        slack({ webhook: process.env.SLACK_WEBHOOK }),
      ],
    });

two minutes to a typed deploy

Deploy your first contract.