Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions solidity/docs/rocketh-feasibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Hardhat 3 / Rocketh feasibility decision

Status: local feasibility checks passed on 2026-09-08, based on `dev` at
`cbd6e4d2be9ab46d7d6f4096eafc163d36c1a685`. Tracks
[#4295](https://github.com/threshold-network/keep-core/issues/4295).

Proceed with controlled implementation of option B: Rocketh/viem for deployment,
ethers v6 with Mocha/Chai for tests. Keep maintained deploy v1 / Hardhat 2 as the
current release line. Prefer a new versioned ESM/Rocketh release line with a
maintained legacy CJS line until its consumers migrate.

The Fable 5.1 max-effort consultation recommended that release strategy, and the
local compatibility experiment supports the architecture's feasibility. It does
not establish agreement from upstream or downstream maintainers. Same-package
dual publication remains possible if consumer requirements justify its additional
runtime compatibility and release checks.

## Evidence

The reproducible [experiment](../experiments/rocketh-compatibility/README.md)
passed all 10 checks with Node 24.11.1 and npm 11.6.2.

| Boundary | Result |
| --- | --- |
| Published scripts | A producer is packed and installed into node_modules. Its generated source directory is then removed; the consumer loads the packed scripts and their JSON artifact. |
| Cross-package discovery | The Rocketh loader accepts explicit script directories. A consumer file that sorts before its producer still resolves the producer through a tag dependency. An unselected script would throw if executed. |
| Runtime accounts | The producer deploys with the consumer's named accounts, including nondefault deployer and owner indices. |
| Fixtures and reruns | The ethers v6 fixture restores changed contract state. A second Rocketh execution preserves addresses and broadcasts no additional transaction. |
| v1 baseline | A separate Hardhat 2 / deploy-v1 process receives the same compiled artifacts and produces matching addresses and ABIs. |
| Export JSON | A prototype adapter produces byte-identical v1 JSON for both probe contracts, including linkedData. It explicitly restores v1's checksummed address representation. |
| Native Rocketh export | Its top-level chain object and per-contract startBlock differ from the legacy single-network export schema. A compatible exporter is still necessary. |
| Legacy proxies | OpenZeppelin's Hardhat 3 API supports validated implementation deployment, importing two explicit Contracts 4 proxies with a shared admin, rejecting a bad layout, upgrading an existing proxy, transferring admin ownership, and replacing one proxy's admin. |

Both compared JSON files contain 2,279 bytes and have SHA-256
`669bd1ed435bed2b89e2a0677c47a922674ab77b4702b0194722fda6750dbcff`.

The target fixture pins Hardhat 3.15.0, hardhat-deploy 2.0.26, Rocketh and
@rocketh/node 0.21.0, ethers 6.17.0, and @openzeppelin/hardhat-upgrades 4.1.0.
The baseline pins Hardhat 2.26.3 and hardhat-deploy 1.0.4. Full dependency versions
are locked independently.

The v1/ESM comparison deliberately uses the same compiled artifacts. It proves
deployment-engine and export handling for this fixture, not equivalence of
Hardhat 2 and Hardhat 3 compilation of the production contracts.

## Implications for the existing proposal

The external-script question has a tested implementation candidate: configure
the Rocketh loader with resolved directories from installed packages. The actual
upstream packages still need compatible scripts and an agreed public entry point.

A compatible single-network JSON exporter is feasible for the demonstrated
surface. The prototype is not a replacement for `hardhat export-artifacts`,
`--export-all`, or historical production deployment records. File ordering,
optional fields, bytecode, linking, and historical formats require a complete
consumer inventory before this becomes production code.

OpenZeppelin now publishes a Hardhat 3-compatible plugin: 4.1.0 was installed and
exercised here. This removes the question of whether an HH3 plugin exists, but
does not make existing helpers interchangeable. The experiment explicitly creates
legacy proxies/admins; it does not select Contracts 5 defaults or a final helper
adapter. In particular, existing `upgrades.admin.getInstance()` callers require
a deliberate replacement using the appropriate recorded admin address.

## Next implementation steps and release gates

1. Continue the prerequisite stack. #4294 has merged into dev. #4299's ES2020/CJS
change and #4305's Waffle removal were still open at the time of this experiment.
Ethers v6 and the real helper/plugin migration remain separate work.
2. Agree the public ESM script entry points and release channels with the upstream
contracts package and tbtc-v2. Release in dependency order:
solidity-contracts → random-beacon → ecdsa → tbtc-v2. Pin exact dependencies
during the transition; ECDSA's current `development` tag must not select an
incompatible upstream release accidentally.
3. Convert a representative real upstream/beacon path, then a WalletRegistry path,
exercising actual packed packages. Account for stubs, network tags, linked
libraries, deployment IDs, skip rules, ownership changes, and restart behavior.
4. Extend the output comparison to the real published artifacts and committed
mainnet/sepolia records. Preserve existing data contracts; coordinate every
deliberate difference with consumers. Never regenerate production records as
a side effect of this experiment.
5. Settle proxy/admin semantics with
[tbtc-v2 #1130](https://github.com/threshold-network/tbtc-v2/issues/1130).
Validate actual manifests, existing-proxy upgrades, shared and nondefault admins,
storage layouts, governance/deputy behavior, and implementation/proxy/admin
verification. The toy test does not cover these production integrations.
6. Before releasing the full port, run both complete Solidity suites, fresh
integrated local deployments, and downstream tarball checks. Maintain CJS
support on the old line until its consumers migrate. Keep #4295 open until
those acceptance checks pass.

No contract package was migrated or published, and no production deployment or
GitHub mutation was performed by this experiment.
7 changes: 7 additions & 0 deletions solidity/experiments/rocketh-compatibility/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.run/
node_modules/
artifacts/
cache/
.openzeppelin/
deployments/
generated/
1 change: 1 addition & 0 deletions solidity/experiments/rocketh-compatibility/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24.11.1
52 changes: 52 additions & 0 deletions solidity/experiments/rocketh-compatibility/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Rocketh deployment compatibility experiment

This isolated fixture supports [keep-core #4295](https://github.com/threshold-network/keep-core/issues/4295).
It tests the proposed Rocketh deployment / ethers v6 test boundary using packed
producer scripts and two in-process Hardhat networks. It does not change either
production package's toolchain.

Tested with Node 24.11.1 and npm 11.6.2. Use the checked-in lockfiles:

```sh
cd solidity/experiments/rocketh-compatibility
npm ci --ignore-scripts --no-audit --no-fund
npm ci --prefix baseline --ignore-scripts --no-audit --no-fund
npm test
```

The test run uses a local, pinned solc-js compiler, packs and installs the producer
without publishing it, and runs all deployments on in-process simulated networks.
The temporary producer installation is not saved to package.json or the lockfile.
It requires the installed dependencies and their npm cache; the temporary package
installation runs offline. Generated files stay under ignored directories.

The runner first compiles the probe contracts. It gives the same compiled
artifacts to a separate Hardhat 2 / hardhat-deploy v1 baseline, then compares
that engine's actual deployment/export results with Hardhat 3 / Rocketh.

The 10 checks cover:

- Loading scripts and their artifacts from an npm tarball.
- Cross-package tag dependencies and consumer-provided accounts.
- Idempotent reruns and ethers v6 snapshot fixtures.
- Equal deployment addresses and ABIs across the two engines.
- Byte-identical single-network v1 JSON from a prototype adapter, including
linkedData and checksummed contract addresses.
- The different native Rocketh JSON schema.
- Two explicitly constructed Contracts 4 proxies sharing a ProxyAdmin.
- Rejection of an incompatible storage layout, a successful existing-proxy upgrade,
ownership transfer, and replacement of one proxy's admin.

The proxy setup uses validated implementation deployment and forceImport through
OpenZeppelin's Hardhat 3 API. It is an example of preserving legacy behavior, not
a selected production adapter. No validation bypass is used.

Inspect `.run/v1-export.json`, `.run/v2-legacy-export.json`,
`.run/v2-native-export.json`, `.run/v2-records.json`, and
`.run/packed-files.json` after a successful run.

This does not establish compatibility for the real upstream scripts,
`export/artifacts`, compiler output, verification integrations, production
deployment records/manifests, or WalletRegistry's linked libraries and full
proxy/deputy workflow. See [the decision record](../../docs/rocketh-feasibility.md)
for the implementation gates.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module.exports = async ({ deployments, getNamedAccounts }) => {
const { deployer } = await getNamedAccounts();
const probe = await deployments.get("Probe");
await deployments.deploy("DependentProbe", {
from: deployer, args: [probe.address], log: false,
});
};
module.exports.tags = ["DependentProbe"];
module.exports.dependencies = ["Probe"];
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = async ({ deployments, getNamedAccounts }) => {
const { deployer, owner } = await getNamedAccounts();
await deployments.deploy("Probe", {
from: deployer, args: [owner, 7], log: false,
linkedData: { purpose: "compatibility", revision: 1 },
});
};
module.exports.tags = ["Probe"];
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
require("hardhat-deploy");

module.exports = {
solidity: "0.8.17",
paths: {
artifacts: "../.run/v1-artifacts",
cache: "../.run/v1-cache",
deployments: "../.run/v1-deployments",
},
namedAccounts: { deployer: 2, owner: 3 },
networks: { hardhat: { chainId: 31337, hardfork: "cancun" } },
};
Loading
Loading