rocketh and hardhat-deploy Documentation
Introduction
What is rocketh?
rocketh is a framework-agnostic system for deploying smart contracts on Ethereum-compatible networks. It provides a minimal API to save and load deployments, making it easy to track and manage contract deployments across different networks.
Key features of rocketh include:
- Deployment tracking and management
- Deploy Scripts that can run anywhere, including in the browser
- Named accounts for easier contract interaction
- Deterministic deployments
- Library linking
- Support for various deployment strategies
What is hardhat-deploy?
hardhat-deploy is a plugin for the Hardhat Ethereum development environment that leverages rocketh to provide a comprehensive deployment system. It makes it easy to deploy contracts to any network, keeping track of them and replicating the same environment for testing.
Key features of hardhat-deploy include:
- Integration with Hardhat's testing and task system
- Deployment scripts with tags and dependencies
- Named accounts for clearer tests and deployment scripts
- Support for specific deploy scripts per network
- Deployment retrying through saved pending transactions
Relationship Between rocketh and hardhat-deploy
hardhat-deploy v2 is a complete rewrite that uses rocketh under the hood. While rocketh provides the core deployment functionality, hardhat-deploy integrates it with the Hardhat environment, making it accessible through Hardhat tasks and configuration.
rocketh is designed to be modular, with core functionality provided by separate packages like @rocketh/deploy, @rocketh/proxy, and @rocketh/diamond. hardhat-deploy wires these modules together and adds Hardhat-specific functionality.
Architecture Overview
rocketh Architecture
rocketh follows a modular architecture with several key components:
- Core Package (
rocketh): Provides the basic environment and deployment tracking functionality. - Deploy Package (
@rocketh/deploy): Adds thedeployfunction to the environment. - Proxy Package (
@rocketh/proxy): Adds proxy deployment capabilities. - Diamond Package (
@rocketh/diamond): Adds diamond pattern deployment capabilities. - Export Package (
@rocketh/export): Provides functionality to export deployments for use in frontends. - Verifier Package (
@rocketh/verifier): Provides contract verification capabilities for Etherscan, Sourcify, etc. - Doc Package (
@rocketh/doc): Generates documentation for deployed contracts. - Unknown Signer Package (
@rocketh/unknown-signer): AddscatchUnknownSigner, for calls whosefromis an account rocketh cannot sign for (a Safe multisig, a hardware wallet, a governance key).
Each package extends the core with additional functionality, allowing you to use only what you need.
hardhat-deploy Architecture
hardhat-deploy integrates rocketh with Hardhat through:
- Plugin Registration: Registers the
deploytask with Hardhat. - Config Hook Handler: Processes Hardhat configuration to set up rocketh.
- Solidity Hook Handler: Processes Solidity compilation results for use with rocketh.
- Deploy Task: Executes deployment scripts using rocketh's
loadAndExecuteDeploymentsfunction.
Installation and Setup
Installing rocketh and hardhat-deploy
# Using npm
npm install -D hardhat-deploy rocketh @rocketh/node @rocketh/deploy @rocketh/read-execute
# Using pnpm
pnpm add -D hardhat-deploy rocketh @rocketh/node @rocketh/deploy @rocketh/read-executeNote that @rocketh/node is required for hardhat-deploy to function. this is a package that let rocketh read file and folders
For additional functionality, you can install these optional packages:
# Using npm
npm install -D @rocketh/proxy @rocketh/diamond @rocketh/export @rocketh/verifier @rocketh/doc
# Using pnpm
pnpm add -D @rocketh/proxy @rocketh/diamond @rocketh/export @rocketh/verifier @rocketh/docSetting Up Your Project
There are several ways to configure rocketh, but here is our recommended approach
- **Create a
rockethfolder and addconfig.ts/jsfile (it has to be named this way) **
As you ll see by reading it, we also add some extra to make it easier to use later
// rocketh/config.ts
/// ----------------------------------------------------------------------------
// Typed Config
// ----------------------------------------------------------------------------
import type {UserConfig} from 'rocketh/types';
// we define our config and export it as "config"
export const config = {
accounts: {
deployer: {
default: 0,
},
admin: {
default: 1,
},
},
data: {},
} as const satisfies UserConfig;
// then we import each extensions we are interested in using in our deploy script or elsewhere
// this one provide a deploy function
import * as deployExtension from '@rocketh/deploy';
// this one provide read,execute functions
import * as readExecuteExtension from '@rocketh/read-execute';
// this one provide a deployViaProxy function that let you declaratively
// deploy proxy based contracts
import * as deployProxyExtension from '@rocketh/proxy';
// this one provide a viem handle to clients and contracts
import * as viemExtension from '@rocketh/viem';
// and export them as a unified object
const extensions = {
...deployExtension,
...readExecuteExtension,
...deployProxyExtension,
...viemExtension,
};
export {extensions};
// then we also export the types that our config ehibit so other can use it
type Extensions = typeof extensions;
type Accounts = typeof config.accounts;
type Data = typeof config.data;
export type {Extensions, Accounts, Data};- We also want to create 2 more files:
rocketh/deploy.ts/jsandrocketh/environment.ts/js(you can name them whatever you want)
These files create the variosu utility functions we wll need later
deploy.tsmake use of onlyrockethand allow deploy script to run in a web runtime if desired.environment.tsmake use ofrocketh/nodeto read the config file and is export function to be used in tests or scripts
// rocketh/deploy.ts
import {type Accounts, type Data, type Extensions, extensions} from './config.js';
// ----------------------------------------------------------------------------
// we re-export the artifacts, so they are easily available from the alias
import * as artifacts from '../generated/artifacts/index.js';
export {artifacts};
// ----------------------------------------------------------------------------
// we create the rocketh functions we need by passing the extensions to the
// setup function
import {setupDeployScripts} from 'rocketh';
const {deployScript} = setupDeployScripts<Extensions, Accounts, Data>(extensions);
export {deployScript};// rocketh/environment.ts
import {type Accounts, type Data, type Extensions, extensions} from './config.js';
import {setupEnvironmentFromFiles} from '@rocketh/node';
import {setupHardhatDeploy} from 'hardhat-deploy/helpers';
// useful for test and scripts, uses file-system
const {loadAndExecuteDeploymentsFromFiles} = setupEnvironmentFromFiles<Extensions, Accounts, Data>(extensions);
const {loadEnvironmentFromHardhat} = setupHardhatDeploy<Extensions, Accounts, Data>(extensions);
export {loadEnvironmentFromHardhat, loadAndExecuteDeploymentsFromFiles};- Create a
deployfolder for your deployment scripts.
Core Concepts
Deployments and Environments
A deployment in rocketh represents a deployed contract on a specific environment.
An environment is a named network. You can thus have multiple environment for the same chain.
For example you can have sepolia environment and the sepolia2 environment both pointing to the same chain but having different deployments.
Each deployment includes:
- Contract address
- ABI
- Bytecode
- Constructor arguments
- Transaction details
- Metadata for verification
Deployments are saved to disk in the deployments/<environment> folder, allowing them to be tracked in version control and reused.
Named Accounts
Named accounts allow you to refer to accounts by name rather than index or address. This makes your deployment scripts and tests more readable and maintainable.
Named accounts are configured in rocketh/config.ts:
export const config = {
accounts: {
deployer: {
default: 0,
sepolia: 1,
},
admin: {
default: 1,
},
},
} as const satisfies UserConfig;In this example, deployer refers to the first account (index 0) on all environment except Sepolia, where it refers to the second account (index 1).
Deploy Scripts
Deploy scripts are JavaScript or TypeScript files that define how contracts should be deployed. They use the execute function from rocketh to define a deployment function and its metadata (tags and dependencies).
Deploy scripts are placed in the deploy folder and are executed in alphabetical order when running the hardhat deploy task.
Tags and Dependencies
Tags and dependencies allow you to control which deploy scripts are executed and in what order.
- Tags: Labels attached to deploy scripts that can be used to selectively execute them.
- Dependencies: Tags that a deploy script depends on, ensuring those scripts are executed first.
Using rocketh
The Environment Object
The environment object is passed to each deploy function and contains:
- Network information
- Named accounts and signers
- Functions to save and load deployments
- Functions provided by rocketh modules
Deploying Contracts
The deploy function from @rocketh/deploy is used to deploy contracts:
import {deployScript, artifacts} from '#rocketh';
export default deployScript(
async ({deploy, namedAccounts}) => {
const {deployer} = namedAccounts;
await deploy('GreetingsRegistry', {
account: deployer,
artifact: artifacts.GreetingsRegistry,
args: [''],
});
},
{tags: ['GreetingsRegistry', 'GreetingsRegistry_deploy']},
);When does a re-run REDEPLOY?
A deploy script is meant to be re-runnable, so deploy first asks whether the contract it is about to deploy is already deployed. Three options control that question:
| option | effect |
|---|---|
skipIfAlreadyDeployed | if a deployment with this name exists, return it and look no further — the code is never compared |
alwaysOverride | redeploy unconditionally, comparing nothing (mutually exclusive with skipIfAlreadyDeployed, which throws) |
strictBytecodeMatch | how to compare, when comparing happens — see below |
With neither skipIfAlreadyDeployed nor alwaysOverride, rocketh compares the saved deployment's code and constructor arguments against what you are deploying now, and redeploys only if they differ.
By default that comparison ignores the contract metadata. Solidity appends a CBOR metadata blob to a contract's runtime bytecode, and that blob changes when things that do not affect behaviour change: a comment, an absolute source path, a compiler patch version. Comparing raw bytes would therefore redeploy — or, worse, UPGRADE A PROXY — because someone reformatted a file. So rocketh strips the metadata from both sides before comparing (docs/adr/0004-non-strict-bytecode-matching-by-default.md). @rocketh/proxy forces this off for exactly that reason and does not let you change it.
Set strictBytecodeMatch: true to compare the bytes verbatim instead, metadata included:
await deploy(
'GreetingsRegistry',
{account: deployer, artifact: artifacts.GreetingsRegistry, args: ['']},
{strictBytecodeMatch: true},
);Use it when you need the deployed contract to correspond to one exact compilation — typically because a verification or attestation flow pins the metadata hash, so a metadata-only difference genuinely IS a different artifact to you. The cost is that recompiling on a different machine, or with a patch-level compiler bump, will redeploy.
Deploying Proxies
The deployViaProxy function from @rocketh/proxy allows you to deploy upgradeable contracts:
import {deployScript, artifacts} from '../rocketh/deploy.js';
export default deployScript(
async (env) => {
const {deployer, admin} = env.namedAccounts;
console.log({deployer, admin});
const prefix = 'proxy:';
const deployment = await env.deployViaProxy(
'GreetingsRegistry',
{
account: deployer,
artifact: artifacts.GreetingsRegistry,
args: [prefix],
},
{
owner: admin,
linkedData: {
prefix,
admin,
},
},
);
},
// execute takes as a second argument an options object where you can specify tags and dependencies
{tags: ['GreetingsRegistry', 'GreetingsRegistry_deploy']},
);Deploying Diamonds
The diamond function from @rocketh/diamond allows you to deploy contracts using the Diamond pattern:
import {deployScript, artifacts} from '../rocketh/deploy.js';
export default deployScript(
async ({diamond, namedAccounts}) => {
const {deployer, admin} = namedAccounts;
await diamond(
'MyDiamond',
{
account: deployer,
facets: [
{
name: 'DiamondCutFacet',
artifact: artifacts.DiamondCutFacet,
},
{
name: 'DiamondLoupeFacet',
artifact: artifacts.DiamondLoupeFacet,
},
{
name: 'OwnershipFacet',
artifact: artifacts.OwnershipFacet,
},
],
},
{
owner: admin,
},
);
},
{tags: ['MyDiamond', 'MyDiamond_deploy']},
);Linking Libraries
rocketh supports linking libraries at deployment time:
import {deployScript, artifacts} from '../rocketh/deploy.js';
export default deployScript(
async ({deploy, namedAccounts}) => {
const {deployer} = namedAccounts;
// Deploy the library first
const exampleLibrary = await deploy('ExampleLibrary', {
account: deployer,
artifact: artifacts.ExampleLibrary,
});
// Deploy a contract that uses the library
await deploy(
'Example',
{
account: deployer,
artifact: artifacts.Example,
args: ['example string argument'],
},
{
libraries: {
ExampleLibrary: exampleLibrary.address,
},
},
);
},
{tags: ['Example', 'Example_deploy']},
);Deterministic Deployments
rocketh supports deterministic deployments using CREATE2:
import {deployScript, artifacts} from '../rocketh/deploy.js';
export default deployScript(
async ({deploy, namedAccounts}) => {
const {deployer} = namedAccounts;
await deploy(
'GreetingsRegistry',
{
account: deployer,
artifact: artifacts.GreetingsRegistry,
args: [''],
},
{
deterministic: true, // or a specific salt: "0x123..."
},
);
},
{tags: ['GreetingsRegistry', 'GreetingsRegistry_deploy']},
);Using hardhat-deploy with rocketh
Configuring hardhat-deploy
hardhat-deploy is configured in your hardhat.config.js or hardhat.config.ts file:
Running Deployments
To run your deployment scripts, use the hardhat deploy task:
npx hardhat deploy --network sepoliaYou can also run specific tags:
npx hardhat deploy --network sepolia --tags GreetingsRegistryUsing Deployments in Tests
You can use deployments in your Hardhat tests:
Advanced Features
Contract Verification
The @rocketh/verifier package provides contract verification capabilities:
npx rocketh-verify -e sepolia etherscanExporting Deployments
The @rocketh/export package allows you to export deployments for use in frontends:
npx rocketh-export -e sepolia --ts ./src/contracts.tsGenerating Documentation
The @rocketh/doc package generates documentation for your contracts:
npx rocketh-docHandling unknown signers (Safe / multisig owners)
When a privileged call targets an account rocketh cannot sign for, the transaction surfaces as an UnknownSignerError carrying exactly what has to be executed out-of-band. The @rocketh/unknown-signer package lets you catch it, keep the run going, and get that transaction back:
npm install -D @rocketh/unknown-signerRegister it as an extension in rocketh/config.ts, exactly like @rocketh/deploy and @rocketh/read-execute:
import * as deployExtension from '@rocketh/deploy';
import * as readExecuteExtension from '@rocketh/read-execute';
import * as unknownSignerExtension from '@rocketh/unknown-signer';
const extensions = {...deployExtension, ...readExecuteExtension, ...unknownSignerExtension};
export {extensions};and then it arrives on the environment your deploy script is handed, with no env to thread:
export default deployScript(
async ({deploy, execute, catchUnknownSigner, namedAccounts, artifacts}) => {
// NOTE the call shape: the action is a FUNCTION, not an already-started promise.
// This is the one mechanical change from a hardhat-deploy v1 script
// (v1: `catchUnknownSigner(execute(...))`), because a promise has already begun
// executing before the wrapper can establish its policy scope. The v1 form is a
// compile error, and a JavaScript caller gets a runtime error naming the fix.
const deferred = await catchUnknownSigner(() =>
execute(proxy, {account: 'safeOwner', functionName: 'upgradeTo', args: [newImplementation.address]}),
);
if (deferred) {
// {from, to, value, data} — execute this on the Safe, then re-run the script.
}
},
{tags: ['Upgrade']},
);Outside a deploy script (a test, a standalone script) you have an Environment in hand rather than an enhanced one, so call the same functions curried:
import {catchUnknownSigner} from '@rocketh/unknown-signer';
import {execute} from '@rocketh/read-execute';
const deferred = await catchUnknownSigner(env)(() =>
execute(env)(proxy, {account: 'safeOwner', functionName: 'upgradeTo', args: [newImplementation.address]}),
);Both forms are the same function: an extension package's root exports only curried (env) => … functions, which is precisely what lets the spread above turn them into methods on the environment.
It returns null when the action succeeded, and otherwise hardhat-deploy v1's exact shape: every key present even when undefined, value as a string. Pass {log: false} to suppress the printed block. Nothing is persisted — idempotency comes from on-chain state, so you execute the transaction on your Safe and re-run the idempotent script. One wrapper captures one transaction (the first unsignable one inside it), so deferring several steps means one catchUnknownSigner per step.
Resolving it interactively instead (onUnknownSigner: 'ask')
If you are at a keyboard, rocketh can PAUSE instead of throwing: it prints the transaction, waits while you execute it out-of-band (on your Safe, a hardware wallet, an air-gapped machine), takes back the transaction hash you paste, and CONTINUES the same run with the deployment state saved. No re-run dance, and an action with several unsignable steps pauses at each one and finishes them all in a single run.
The behaviour is chosen by onUnknownSigner, resolved as CLI flag / execution parameter > chain config > top-level config > the default 'auto':
| value | what happens when a from is unsignable |
|---|---|
'throw' | raise UnknownSignerError immediately (the defer workflow above) |
'ask' | pause and ask, when the run can ask a human for text; otherwise behave as throw |
'auto' | the default: ask when the run can ask a human for text, throw when it cannot |
Set it for a whole chain in rocketh/config.ts:
export const config = {
accounts: {
/* ... */
},
chains: {
11155111: {onUnknownSigner: 'ask'},
},
data: {},
} as const satisfies UserConfig;Set it once for EVERY chain with the top-level key, so "never prompt me anywhere" does not have to be repeated per chain entry (a chains[id] entry still overrides it):
export const config = {
accounts: {
/* ... */
},
onUnknownSigner: 'throw',
data: {},
} as const satisfies UserConfig;or for one run, which wins over both:
await loadAndExecuteDeploymentsFromFiles({environment: 'sepolia', onUnknownSigner: 'ask'});or for one INVOCATION from your shell, which is the same run-level lever:
rocketh -e sepolia --on-unknown-signer throw
pnpm hardhat deploy --network sepolia --on-unknown-signer throw--skip-prompts (on both CLIs) forces throw, because the interactive resolver IS a prompt. It wins over an explicit --on-unknown-signer ask: asking to be prompted and not prompted at once is a contradiction, and not prompting is the safe half.
"Can the run ask a human for text?" is a CAPABILITY of the runtime, not a preference: it is true only when the run carries a PromptExecutor that implements promptText. @rocketh/node (the rocketh CLI and the hardhat-deploy path) supplies one only when stdin is a terminal; @rocketh/web deliberately never does, because a browser cannot sensibly ask you to paste a transaction hash. So a CI job, whose stdin is not a terminal, has no text capability at all and takes the throw path: it never blocks on a prompt, even under 'auto' and even if a script hardcodes 'ask'. Capability is a CEILING, not a default. (The TTY check is not politeness: the underlying prompt library, asked a question with no terminal behind it, never answers and never fails, so the only safe move is not to ask.)
At the pause you have two answers:
- paste the transaction hash — rocketh looks the transaction up on the network, waits for it to be mined, requires the receipt to report a SUCCESSFUL status, saves state through the same pending-transaction path a normal broadcast uses, records the hash for gas reporting, and returns the receipt to your script. It never sends a transaction of its own. A hash this node has never heard of (from the wrong network, or a typo that is still the right shape) is given a short grace period to show up and then reported as not found, with the transaction you still have to execute printed again, so the run stops rather than waiting for ever.
cannot sign(or just press enter) — rocketh prints the full transaction and throws the sameUnknownSignerErroras the non-interactive path, so the interactive flow degrades cleanly into the defer workflow and is still caught bycatchUnknownSigner. Aborting the prompt (Ctrl-C) does the same. A paste that is not a transaction hash is re-asked a couple of times and then also defers.
catchUnknownSigner takes the throw path whatever the AMBIENT policy is: a wrapped action never pops a prompt at you, because you already said you would handle the transaction yourself. The one thing that overrides it is an EXPLICIT override written inside the wrapper, because policy frames nest and the innermost one wins — so catchUnknownSigner(env)(() => withUnknownSignerPolicy(env)('ask', ...)) does prompt. That is deliberate: the guarantee is about the policy you did not state, not about silencing one you wrote yourself a line later.
A DEPLOYMENT from an unsignable from pauses and asks in exactly the same way, and is then held to a STRICTER standard than an execution, because it has an address to anchor on. The address rocketh records is never taken on trust from the hash you paste:
- an ordinary deployment is recorded at the address the pasted transaction's OWN receipt reports as created;
- a deterministic (or factory) deployment, whose address was computed from bytecode and salt before broadcast, is recorded at that expected address only once rocketh has seen CODE at it on-chain. It confirms by looking for the code, never by parsing the transaction, so it does not matter what wrapper your multisig executed it inside.
Anything else FAILS, saving nothing: a receipt that reports no created contract (or the zero address), an expected address with no code at it, a transaction that did not succeed, or a node that cannot answer the code lookup at all (unable to confirm is not the same as confirmed, so the run fails rather than recording a deployment nobody verified). The error names the deployment, the hash you pasted and the transaction that still needs executing, so a wrong hash cannot quietly leave you with a deployment record pointing at an address holding nothing.
This applies to the interactive path only. A deployment rocketh broadcast itself is unaffected and gains no new check: it sent that transaction, so there is nothing to distrust.
Choosing the policy for ONE call (withUnknownSignerPolicy)
The policy above applies to the whole run. withUnknownSignerPolicy overrides it for a single action — typically to REHEARSE the interactive flow on a fork before doing it on mainnet:
import {withUnknownSignerPolicy} from '@rocketh/unknown-signer';
// this one call pauses and asks, even though the run's policy is 'throw'
const receipt = await withUnknownSignerPolicy(env)('ask', () =>
execute(env)(proxy, {account: 'safeOwner', functionName: 'upgradeTo', args: [newImplementation.address]}),
);It takes a function for the same reason catchUnknownSigner does, returns whatever the action returned, and propagates whatever it threw (so wrapping it in catchUnknownSigner still defers). Precedence is one rule: the innermost override wins, then the run parameter, then the chain config, then the default 'auto'.
It accepts the whole policy vocabulary, 'auto' included, not just 'ask' and 'throw'. Per call, 'auto' means "use this run's capability-aware default for this one action", which is the only way to opt a single call back OUT of a run-level 'throw' without deciding for it what to do instead.
The override chooses among what the run can do; it cannot exceed it. Asking for 'ask' where the run cannot ask a human for text still takes the throw path and never prompts, so a script that hardcodes the override is still safe in CI. And since it is the same policy frame, it never turns a signable account into a throw and never defeats impersonation.
ACCEPTED RESIDUAL RISK, stated rather than engineered around: for an EXECUTION, rocketh checks that the transaction you pasted succeeded, and nothing else. It does not decode MultiSend or Timelock payloads and does not try to match to/data, because a governed execution is routinely wrapped by the multisig into a different transaction shape. A successful-but-unrelated hash would therefore be accepted. This is the same trust boundary as hardhat-deploy v1 (where the run continued after you executed the transaction, with no check at all), only stricter, and it exists because an execution has no address to anchor on.
In the browser, and on a fork: impersonation instead of interactivity
@rocketh/web implements no text prompt today, so a browser run cannot ask you to paste a transaction hash: 'ask' (and 'auto') take the throw path there, exactly as in CI. That is a deliberate absence rather than an oversight — asking in a browser means a real UI integration point (a modal, a form, somewhere for the answer to come from), which is a different kind of thing from reading a line of stdin, and nothing forces that decision yet. It is also not a dead end. On a FORK or a dev node the browser has a better answer than interactivity anyway: let the account be IMPERSONATED, which resolves it BEFORE the unknown-signer seam so no policy is ever consulted and nothing has to be executed out-of-band.
// rocketh/config.ts
export const config = {
accounts: {
deployer: {default: 0},
// the Safe / timelock / owner you want the fork to sign for MUST be named here
safeOwner: {default: '0x1111111111111111111111111111111111111111'},
},
data: {},
} as const satisfies UserConfig;// in the browser (@rocketh/web), against a fork or dev node
import {setupEnvironment} from '@rocketh/web';
const {loadAndExecuteDeploymentsFromModules} = setupEnvironment(config, {});
await loadAndExecuteDeploymentsFromModules(modules, {provider, autoImpersonate: true});Three constraints make this work, and none of them is a formality:
- Naming the addresses is MANDATORY, not merely convenient. Only NAMED accounts are impersonation candidates. An address that appears nowhere in
accounts(an unnamed account, or a barefrompassed to a call) is never impersonated, however capable the node is, and still lands on the unknown-signer seam. Naming is necessary but not on its own sufficient: the candidates are the named accounts the NODE would otherwise have to sign for, so a named account that already resolves to its own signer (a private key, a wallet) signs directly and is never impersonated, which is what you want anyway. - It needs a node that implements the impersonation RPC, meaning a fork or a dev node (anvil, hardhat). Against a real chain the account simply stays unsignable and the run takes the throw-and-defer path, which is the CORRECT outcome: nothing should be able to fake a signature on mainnet.
autoImpersonateis RUN-level, not per-transaction. It is set for the whole run (execution parameter or chain config), like every other node capability. There is no per-call impersonation knob; a per-call variant is a separate, out-of-scope idea.
If you enable it against a node that does NOT implement the RPC, the attempt is swallowed (that is what lets the switch be harmless on an ordinary provider), but the unknown-signer error you eventually get SAYS SO: it tells you auto-impersonation was enabled and hardhat_impersonateAccount was refused, or that this account was never a candidate at all (it is not one of the named accounts the node would have to sign for). With auto-impersonation off, the error says nothing about it at all.
Note that this is a NODE CAPABILITY and onUnknownSigner is a POLICY: they are orthogonal, and there is no 'impersonate' policy value. Impersonation runs first and, when it works, the policy is never reached. Conversely, catchUnknownSigner and withUnknownSignerPolicy never defeat impersonation: to exercise the unknown-signer path on a fork, set autoImpersonate: false for the run.
Testing your deploy scripts
The @rocketh/test-utils package provides createTestEnvironment, an async harness that constructs a REAL rocketh environment against a mock EIP-1193 provider. It lets you drive deploy / execute / read calls end-to-end without a node.
npm install -D @rocketh/test-utilsimport {describe, it, expect} from 'vitest';
import {deploy} from '@rocketh/deploy';
import {createTestEnvironment, createMockArtifact} from '@rocketh/test-utils';
describe('MyContract deployment', () => {
it('deploys with a named account', async () => {
const {env, provider} = await createTestEnvironment({
// UserConfig.accounts shape: a numbered index, a private key, a protocol
// string like 'privateKey:0x...', a bare address, or a per-network map.
accounts: {deployer: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266'},
nodeAccounts: ['0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266'],
});
const _deploy = deploy(env);
const artifact = createMockArtifact('MyContract');
const deployment = await _deploy('MyContract', {
account: 'deployer',
artifact,
args: [],
});
expect(deployment.newlyDeployed).toBe(true);
expect(provider.getRequests().some((r) => r.method === 'eth_sendTransaction')).toBe(true);
});
});The returned provider handle lets you set canned responses (provider.setResponse) and inspect the calls the environment made (provider.getRequests()). The deploymentStore is Map-backed and can be reused across two createTestEnvironment calls to assert that deployments persist. The full options — including a partial UserConfig / ExecutionParams pass-through, autoImpersonate, autoMine, custom signer protocols — are documented in the package source.
Examples
Basic Deployment
import {deployScript, artifacts} from '../rocketh/deploy.js';
export default deployScript(
async ({deploy, namedAccounts}) => {
const {deployer} = namedAccounts;
await deploy('GreetingsRegistry', {
account: deployer,
artifact: artifacts.GreetingsRegistry,
args: [''],
});
},
{tags: ['GreetingsRegistry']},
);Proxy Deployment
import {deployScript, artifacts} from '../rocketh/deploy.js';
export default deployScript(
async ({deployViaProxy, namedAccounts}) => {
const {deployer, admin} = namedAccounts;
await deployViaProxy(
'GreetingsRegistry',
{
account: deployer,
artifact: artifacts.GreetingsRegistry,
args: ['proxy:'],
},
{
owner: admin,
},
);
},
{tags: ['GreetingsRegistry']},
);Diamond Deployment
import {deployScript, artifacts} from '../rocketh/deploy.js';
export default deployScript(
async ({diamond, namedAccounts}) => {
const {deployer, admin} = namedAccounts;
await diamond(
'MyDiamond',
{
account: deployer,
facets: [
{
name: 'DiamondCutFacet',
artifact: artifacts.DiamondCutFacet,
},
{
name: 'DiamondLoupeFacet',
artifact: artifacts.DiamondLoupeFacet,
},
{
name: 'OwnershipFacet',
artifact: artifacts.OwnershipFacet,
},
],
},
{
owner: admin,
},
);
},
{tags: ['MyDiamond']},
);Deployment with Dependencies
import {deployScript, artifacts} from '../rocketh/deploy.js';
export default deployScript(
async ({deploy, namedAccounts}) => {
const {deployer} = namedAccounts;
await deploy('Token', {
account: deployer,
artifact: artifacts.Token,
args: ['My Token', 'MTK'],
});
},
{tags: ['Token']},
);
// In another file
import {deployScript, artifacts} from '../rocketh/deploy.js';
export default deployScript(
async ({deploy, get, namedAccounts}) => {
const {deployer} = namedAccounts;
const token = await get('Token');
await deploy('TokenSale', {
account: deployer,
artifact: artifacts.TokenSale,
args: [token.address],
});
},
{tags: ['TokenSale'], dependencies: ['Token']},
);Migrating from hardhat-deploy v1 to v2
Changes in Deploy Scripts
In v1:
// deploy/00_deploy_my_contract.js
module.exports = async ({getNamedAccounts, deployments}) => {
const {deploy} = deployments;
const {deployer} = await getNamedAccounts();
await deploy('MyContract', {
from: deployer,
args: ['Hello'],
log: true,
});
};
module.exports.tags = ['MyContract'];In v2:
// deploy/00_deploy_my_contract.ts
import {deployScript, artifacts} from '../rocketh/deploy.js';
export default deployScript(
async ({deploy, namedAccounts}) => {
const {deployer} = namedAccounts;
await deploy('MyContract', {
account: deployer,
artifact: artifacts.MyContract,
args: ['Hello'],
});
},
{tags: ['MyContract']},
);Changes in Configuration
In v1, configuration was in hardhat.config.ts:
namedAccounts: {
deployer: 0,
...
},In v2, configuration is in rocketh/config.ts:
export const config = {
accounts: {
deployer: {
default: 0,
},
...
},
} as const satisfies UserConfig;Conclusion
rocketh and hardhat-deploy provide a powerful and flexible system for deploying and managing smart contracts on Ethereum-compatible networks. By understanding the core concepts and features, you can create robust deployment scripts that work across different environments and networks.
For more information, visit: