Skip to content

Migrating from hardhat-deploy v1 to v2 ​

hardhat-deploy v2 is hardhat-deploy on top of rocketh. Two other documents teach you how to TRANSLATE a project: the step-by-step hardhat-deploy migration guide, and the migration skill you can hand to an AI assistant. Both assume the feature you use exists on the other side.

This page answers the question that decides whether you can move at all: for every hardhat-deploy v1 capability, what replaces it, or that nothing does and why. It is the one place that list lives; the other two documents link here rather than repeating it.

Each capability is marked with one of these:

  • same: it exists, possibly under a different spelling, which the row gives.
  • replaced: something else answers the same need by a different mechanism, which the row names.
  • not supported: a decision was taken not to have it. Plan around it.
  • not supported yet: it is missing today and work to add it is planned. Until it ships, treat it as not supported; no date is promised.

Read the sections that match what your v1 project uses. The ones that fail LATE (at deploy-script run time, or silently) are marked, because those are the ones a type check will not catch for you.

Changes in Deploy Scripts ​

In v1:

typescript
// 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:

typescript
// 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']},
);

What v1 built into deployments, rocketh provides as extensions: packages whose functions you spread into extensions in rocketh/config.ts, and which then arrive on the environment your deploy script receives. deploy comes from @rocketh/deploy, read and execute from @rocketh/read-execute, deployViaProxy from @rocketh/proxy, diamond from @rocketh/diamond, catchUnknownSigner from @rocketh/unknown-signer, and viem clients from @rocketh/viem. A function you use and did not register is simply not on the environment.

A v1 skip export is ignored ​

v1 let a script export func.skip = async (hre) => boolean, a predicate evaluated before the script ran. rocketh has no such hook, and it does not refuse one either: a skip export is IGNORED, so the script runs as if it were not there. Nothing warns you, which is why this is the part to check when porting a script. Two replacements cover what skip was used for.

To skip a script conditionally on this run, return early from its own body. The condition sees the same environment the rest of the script does:

typescript
export default deployScript(
	async (env) => {
		// `testnet` is a tag you declared in rocketh/config.ts
		if (!env.tags['testnet']) {
			return; // what v1's skip() returning true did
		}
		// ...
	},
	{tags: ['DevOnly']},
);

To skip a script PERMANENTLY once it has completed, use the run-once mechanism instead of a predicate: give the script an id and return true at the end. The id is recorded, and later runs against the same environment do not call the script at all. See Script lifecycle for the rules, including why return true must not be reached on a path where a step was deferred.

Changes in Configuration ​

In v1, configuration was in hardhat.config.ts:

typescript
namedAccounts: {
  deployer: 0,
  ...
},

In v2, configuration is in rocketh/config.ts:

typescript
export const config = {
  accounts: {
    deployer: {
      default: 0,
    },
    ...
  },
} as const satisfies UserConfig;

Named accounts ​

v1's namedAccounts moves to rocketh/config.ts as accounts. Most entries move unchanged; the differences are below.

v1 named-account entryrocketh
an index into the node's accounts (deployer: 0)the samesame, but an index the node's list does not reach now THROWS with a message naming the account, where v1 left the name absent
a bare addressthe samesame
the name of another named accountthe samesame; a name that is not in accounts, or a reference cycle, is refused with a message naming it
a per-network map ({default: 1, sepolia: '0x...'})the same, keyed by environment name or chain id, then defaultsame
null for a network ({default: 2, mainnet: null})the same: the name is ABSENT on that network, and TypeScript types it as possibly undefinedsame. Only an explicit null means absent: a name with no entry for the network and no default refuses to start with cannot get account for <name>
privatekey://0x...privateKey:0x..., with the privateKey protocol from @rocketh/signer registered in signerProtocolssame, renamed (:// becomes :)
ledger://..., trezor://...nothing bundlednot supported. The signerProtocols seam remains, for a protocol you write yourself
external://0x... (v1 then prompted for the transaction hash)the plain address. An address rocketh cannot sign for is handled by the unknown-signer flow, which pauses, asks for the hash of the transaction you executed elsewhere, and checks it on chainreplaced, see Handling unknown signers
HARDHAT_DEPLOY_ACCOUNTS_NETWORK (override which network key is looked up)nothingnot supported
automatic impersonation on the hardhat network, --no-impersonationautoImpersonate per chain and per run, with no CLI flagsame, minus the flag
typescript
// rocketh/config.ts
import type {UserConfig} from 'rocketh/types';
import {privateKey} from '@rocketh/signer';

export const config = {
	accounts: {
		deployer: 0,
		// v1: `privatekey://0x...`
		relayer: {default: 1, sepolia: `privateKey:${process.env.RELAYER_PRIVATE_KEY}`},
		// v1 `null`: this account does not exist on mainnet
		faucetOwner: {default: 2, mainnet: null},
	},
	data: {},
	signerProtocols: {privateKey},
} as const satisfies UserConfig;

Unnamed accounts (getUnnamedAccounts()) are env.unnamedAccounts: same.

Proxies ​

Three built-in proxy names changed, and the mistake surfaces late

v1 proxyContractrocketh proxyContract
EIP173Proxy (the v1 default)ERC173Proxy (the rocketh default)
EIP173ProxyWithReceiveERC173ProxyWithReceive
UUPSUUPS
OpenZeppelinTransparentProxySharedAdminOpenZeppelinTransparentProxy
OptimizedTransparentProxySharedAdminOptimizedTransparentProxy

The set is CLOSED: those five names and nothing else. TypeScript rejects a v1 name in a typed deploy script, but the check that always applies happens at deploy-script RUN time, where a name outside the set throws unknown proxy contract <name>. And a port that drops the option altogether gets the default, ERC173Proxy, deployed without complaint: for a v1 transparent or UUPS proxy that is a different contract, with no ProxyAdmin.

A v1 deploy(name, {..., proxy}) becomes deployViaProxy(name, {account, artifact, args}, options) from @rocketh/proxy: the contents of proxy move into the third argument, options. The option names map like this:

v1 proxy optionrocketh deployViaProxy option
proxy: truedeployViaProxy with no optionssame
proxy: 'methodName', or methodNameexecute: 'methodName'same, renamed
execute: {methodName, args}execute: {methodName, args}same
execute: {init, onUpgrade}execute: {init, onUpgrade}same; init and onUpgrade may also be a bare method name
ownerownersame, defaulting to the deploying account as in v1
proxyContract: '<built-in name>'proxyContract: '<rocketh name>'same, RENAMED: see the warning above
proxyContract: '<your artifact name>', with proxyArgsproxyContract: {type: 'custom', artifact, args}same, with the artifact passed explicitly; args takes the proxyArgs template ('{implementation}', '{admin}', '{data}')
viaAdminContract: 'DefaultProxyAdmin'implied by the two SharedAdmin* namessame
viaAdminContract: 'MyAdmin' (a deployment name)proxyContract: {type: 'SharedAdmin...', proxyAdminName: 'MyAdmin'}same. An existing deployment under that name is used as-is
viaAdminContract: {name, artifact}proxyAdminName plus proxyAdminArtifact on the same proxyContract objectsame, on the two SharedAdmin* kinds and on {type: 'custom', ...}
upgradeFunction: {methodName, upgradeArgs}upgradeFunction: {methodName, args}same, the template key renamed; it accepts only the placeholders '{proxy}', '{implementation}', '{data}', '{admin}'
upgradeIndexupgradeIndexsame, decided on the record's numDeployments alone (rocketh keeps no history)
checkABIConflict, checkProxyAdminthe samesame
implementationNamenothing: the implementation is always recorded as <name>_Implementationnot supported
a pre-EIP-173 proxy upgraded through changeImplementationnothingnot supported

Before and after, for a transparent proxy whose admin is your own contract and whose upgrade entry point is not one rocketh picks by itself:

typescript
// v1
await deploy('Registry', {
	from: deployer,
	args: ['hello'],
	proxy: {
		owner: admin,
		proxyContract: 'OpenZeppelinTransparentProxy',
		viaAdminContract: {name: 'RegistryAdmin', artifact: 'RegistryAdmin'},
		upgradeFunction: {methodName: 'upgradeProxy', upgradeArgs: ['{proxy}', '{implementation}', '{data}']},
		execute: {methodName: 'postUpgrade', args: ['hello']},
	},
});
typescript
// v2
await deployViaProxy(
	'Registry',
	{account: deployer, artifact: artifacts.Registry, args: ['hello']},
	{
		owner: admin,
		proxyContract: {
			type: 'SharedAdminOpenZeppelinTransparentProxy',
			proxyAdminName: 'RegistryAdmin',
			proxyAdminArtifact: artifacts.RegistryAdmin,
		},
		upgradeFunction: {methodName: 'upgradeProxy', args: ['{proxy}', '{implementation}', '{data}']},
		execute: {methodName: 'postUpgrade', args: ['hello']},
	},
);

One field a consumer may have read is missing from the proxy deployment: v1 stored implementation: <address> on the <name> record, and rocketh does not (the implementation is its own <name>_Implementation deployment, and rocketh reads the proxy's EIP-1967 slot when it needs the live value). Not supported yet. See Deploying proxies for the rest of deployViaProxy.

catchUnknownSigner in a migrated script ​

deployments.catchUnknownSigner is catchUnknownSigner from @rocketh/unknown-signer, and it returns v1's shape: null when the action went through, otherwise {from, to, value, data} with every key present and value as a string. Three things are worth knowing before you port a script that uses it.

The action is a function, not a promise. v1's catchUnknownSigner(execute(...)) becomes catchUnknownSigner(() => execute(...)). This is the one mechanical change. A promise has already started running by the time the wrapper receives it, so there is no moment left at which to establish the policy the wrapped call runs under. Forgetting the arrow is a LOUD error, not a wrapper that quietly fails to defer: TypeScript rejects it, and a JavaScript caller gets a runtime error that names the fix (Wrap the call in an arrow function: ...).

typescript
// v1
await catchUnknownSigner(execute('Registry', {from: admin}, 'setFee', 5));

// v2
await catchUnknownSigner(() => execute(registry, {account: admin, functionName: 'setFee', args: [5n]}));

Wrapping a call means accepting that the step did not happen. When the wrapper catches, the step was NOT executed, and your script carries on anyway. Anything later in the same script that depends on that step must check the chain before it acts, or must not be there. rocketh unwinds only the wrapped action: deployViaProxy's own post-upgrade execute / onUpgrade call is part of that action, so it is safe automatically; your NEXT statement is not. This was equally true in v1; it was simply never written down. A guard on a later execute is how you state the chain check.

Nothing is persisted. No unsigned-transactions file, and no change to any deployment. The only thing that tells a later run the step is done is the chain, so the flow is the v1 flow: execute the printed transaction on the Safe, then re-run the script. That is also why a run-once script must not reach return true on a path where a step was deferred.

The full behaviour, including the default interactive flow that makes the wrapper unnecessary for a new script, is in Handling unknown signers.

The deployments API ​

Every member of v1's hre.deployments (plus the other HRE additions), and where it went. In a deploy script, "the environment" is the object your function receives.

v1rocketh
deploy(name, options)deploy(name, {account, artifact, args}, options) from @rocketh/deploy; proxies moved to deployViaProxysame, split in two
diamond.deploy(name, options)diamond(name, {account}, options) from @rocketh/diamondsame, see Diamonds
deterministic(name, options): the create2 address BEFORE deployingnothing: deploy with deterministic broadcastsnot supported yet
fetchIfDifferent(name, options)nothing public; deploy still makes the same comparison internally before deciding to redeploynot supported
readDotFile, saveDotFile, deleteDotFilenothingnot supported
save(name, deployment)save on the environmentsame
delete(name)nothingnot supported
get, getOrNullthe same, on the environmentsame
all()deployments on the environmentsame
getDeploymentsFromAddress(address)fromAddressToNamedABI / fromAddressToNamedABIOrNull: the names and their MERGED ABI, not the deploymentsreplaced
getArtifact, getExtendedArtifact (look an artifact up by name at run time)import the generated, typed artifact: artifacts.MyContractreplaced: the typed import is where your ABI types come from
run(tags, options)loadAndExecuteDeploymentsFromFiles({provider, tags}) from your rocketh/environment.tssame
fixture(tags), createFixture(fn)hardhat 3's networkHelpers.loadFixture(fn), over a function that calls loadAndExecuteDeploymentsFromFiles({provider, tags})replaced, see Testing
log(...) (prints only when logging is on)showMessage on the environment always prints; verbosity is set for the whole runreplaced
getNetworkName()name on the environment; on a fork, network.fork says what is simulatedsame
getGasUsed()nothing a script can read; the run prints its total gas when gas reporting is onnot supported
execute(name, options, method, ...args)execute(deployment, {account, functionName, args}), or executeByNamesame
rawTx(tx)tx from @rocketh/read-executesame
read(...)read(deployment, {functionName, args}), or readByNamesame
catchUnknownSigner(action)catchUnknownSigner(() => action) from @rocketh/unknown-signersame, with a thunk: see above
getSigner(address) (an ethers Signer)viem clients from @rocketh/viem (viem.walletClient, viem.getWritableContract(...)); rocketh has no ethers dependencyreplaced
getNamedAccounts(), getUnnamedAccounts()namedAccounts, unnamedAccounts on the environmentsame
getChainId() (a decimal string)network.chain.id on the environment (a number)same
companionNetworks (a second network's deployments, accounts and provider, inside one run)nothingnot supported

Deploy and transaction options ​

v1 optionrocketh
fromaccount, in the first objectsame, renamed
argsargs, typed from the artifactsame
contract (an artifact by name, or inline)artifact: artifacts.Name, always explicitreplaced
skipIfAlreadyDeployed, linkedData, librariesthe same, in the options (third argument)same; libraries next to args does not compile
deterministicDeployment: true | saltdeterministic: true | salt | {type: 'create2' | 'create3', salt}, in the optionssame, and wider
gasLimitgassame, renamed
maxFeePerGas, maxPriorityFeePerGas, value, noncethe samesame
gasPrice (legacy, pre-EIP-1559 transactions)gasPrice on deploy, execute and tx, or transactionType: 'legacy' per chainsame, see Chains without EIP-1559
customDatanothingnot supported
lognothing per call; verbosity is run-level (--log-level on the rocketh CLI)not supported per call
autoMineautoMine per chain in rocketh/config.ts, or per runnot supported per call
waitConfirmationsconfirmationsRequired per chain in rocketh/config.tsnot supported per call
estimatedGasLimit, estimateGasExtranothingnot supported

Diamonds ​

diamond.deploy is diamond from @rocketh/diamond. facets are artifact objects rather than names; facetsArgs, excludeSelectors, defaultCutFacet, defaultOwnershipFacet, owner, execute, deterministicSalt, diamondContractArgs, libraries and linkedData carry over. execute still rides every cut, as in v1, and there is no {init, onUpgrade} split for diamonds. Not supported: diamondContract (replacing the base diamond artifact), cutting a diamond deployed by an older hardhat-deploy through its legacy base, and upgradeIndex (unlike deployViaProxy, diamond has no ordered-upgrade guard; gate a later cut on chain state in your script instead).

Tags, dependencies, run-once scripts ​

v1rocketh
func.tags, func.dependencies, func.runAtTheEnd{tags, dependencies, runAtTheEnd}, the second argument of deployScriptsame
func.id plus return true{id}, plus return truesame: a run-once script
func.skipIGNORED, see abovereplaced by an early return
--tags a,b (scripts carrying ANY of them)the samesame
--tags-require-allnothingnot supported
a script failure wrapped as ERROR processing <path>the script's own error, unwrappednot supported

Networks, the CLI and environment variables ​

rocketh's hardhat 3 plugin registers one task, deploy; the standalone rocketh CLI does the same job outside hardhat.

v1rocketh
deploy --tags, --reset, --no-compilethe same (--reset asks for confirmation)same
deploy --write, --deploy-scripts--save-deployments; scripts in rocketh/config.ts (or --scripts on the rocketh CLI)same, renamed
deploy --silent--log-level on the rocketh CLIsame, renamed
deploy --report-gas--report-gas-used on hardhat deploy; on by default on the rocketh CLI (--skip-gas-report turns it off)same
deploy --pendingtxpending transactions are always recorded when deployments are savedreplaced
deploy --gasprice, --maxfee, --priorityfee (run-wide fee defaults)nothing; per-call fee options remainnot supported
deploy --export, --export-allnothing inside the deploy run: run rocketh-export -e <environment> after the deploynot supported
deploy --watch, --watch-onlynothingnot supported
deploy --no-impersonationautoImpersonate: false per chain or per runnot supported as a flag
the node task override (start a node, copy a network's deployments into localhost, deploy, watch)nothingnot supported
test --deploy-fixturenetworkHelpers.loadFixture in the testreplaced
exportrocketh-export -e <environment> from @rocketh/export (ts, js, json and module forms)same, see Exporting
export --export-all (every network in one file)nothing: one environment per exportnot supported
etherscan-verify, sourcifyrocketh-verify from @rocketh/verifier (Etherscan, Sourcify, and Blockscout)same, minus the solcInput fallback and the --write-post-data dump
export-artifacts, external.contracts, external.deploymentspublish the project as a packagereplaced, see below
paths.importsimport the artifactsreplaced
HARDHAT_DEPLOY_LOG, _FIXTURE, _EXPORT, _EXPORT_ALL, _NO_IMPERSONATION, _ACCOUNTS_NETWORK, _FORKonly HARDHAT_FORK survives, in the hardhat helper; the rest have no environment-variable formnot supported
network livenothing built in: declare a live tag per chainreplaced, see below
network tags, saveDeployments, deploy (per-network script folders)chains[id].tags; saveDeployments; environments[name].scriptssame
deterministicDeployment configper chain, for create2 and create3same, and wider
zkSync (zksync, factoryDeps)nothingnot supported

network.live has no built-in equivalent. rocketh's only default tag is testnet, for a chain its chain information marks as a testnet. Declare live yourself for every chain you deploy to for real, and read it as env.tags['live']; declaring tags for a chain replaces its default tags, so list testnet again where a script relies on it. The migration guide shows the config.

The deployment files ​

rocketh writes one JSON file per deployment, per environment, as v1 did. A tool that READS those files (a frontend, a script, an indexer) may be reading a field that changed:

v1 fieldrocketh
args (the decoded constructor arguments)argsData: the encoded argumentsreplaced; decode it with the ABI if you need the values
transactionHashtransaction.hashsame, moved
receipt (the whole receipt: gasUsed, logs, ...)receipt with blockHash, blockNumber and transactionIndex onlynot supported
history (every previous version)nothing; numDeployments counts themnot supported
implementation (on a proxy)nothing, see Proxiesnot supported yet
solcInputHash and the solcInputs/ foldernothing: the metadata carries the sources, and that is what verification submitsnot supported
methodIdentifiersnothingnot supported
numDeployments, metadata, bytecode, deployedBytecode, libraries, linkedData, devdoc, userdoc, storageLayout, facetsthe samesame
gasEstimatesunder evm.gasEstimatessame, moved
the .chainId file.chain, holding the chain id and the genesis hashsame, and stricter

Consuming another project's contracts and deployments ​

v1 let a project read another project's artifacts (external.contracts, export-artifacts), run its deploy scripts, and see its deployments (external.deployments). rocketh has none of those config keys: the other project is an npm package, and you depend on it.

The project being consumed publishes itself, with an exports map in its package.json exposing its generated artifacts, its deployment files, and its deploy scripts compiled to JavaScript (with tsc, plus tsc-alias if the scripts use path aliases). The paths are that project's own; the shape is:

json
{
	"name": "my-protocol-contracts",
	"exports": {
		"./artifacts": "./generated/artifacts/index.js",
		"./deployments/*": "./deployments/*",
		"./deploy/*": "./dist/deploy/*"
	},
	"files": ["dist", "generated", "deployments"]
}

The consuming project adds it as a dependency, then:

  • imports its artifacts like any module (import * as protocol from 'my-protocol-contracts/artifacts'), which replaces external.contracts.artifacts and export-artifacts;

  • lists its compiled deploy scripts before its own in rocketh/config.ts, so they run inside your run and their tags and dependencies join yours, which replaces external.contracts.deploy:

    typescript
    export const config = {
    	accounts: {deployer: 0},
    	data: {},
    	scripts: ['node_modules/my-protocol-contracts/dist/deploy', 'deploy'],
    } as const satisfies UserConfig;
  • reads its published deployment files as ordinary JSON, which replaces external.deployments.

When there is no answer ​

Everything marked not supported above was decided, and the table is where to find out before you start rather than half way through a port. If something your project depends on is marked not supported yet, the practical choice today is to keep that part on v1 (see When to stay on v1) or to work around it in your own deploy script. If you depend on something this page marks not supported and you think it should be, open an issue on the rocketh repository and describe what your scripts do with it.