You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
@openscan/hardhat-plugin labels a contract (name, ABI, source) only when a top-level transaction deployed it. A contract created inside a transaction is never matched to its artifact, even though that artifact is compiled in the project. That covers a factory calling new Child() (CREATE) or new Child{salt: s}() (CREATE2). The explorer shows such a contract as unverified: no name, no decoded ABI, no source.
Factories are a common deployment pattern (token launchers, pool and vault factories). On a local chain, every contract they produce is opaque.
The obvious workaround is to register each child in the Ignition module with m.contractAt. It works for only one instance per contract name, because the plugin pairs Ignition entries with artifacts by contract name rather than by future id. See m.contractAt doesn't work around it below.
Steps to Reproduce
Any Hardhat 3 project with the plugin enabled works, e.g. packages/example-project in openscan-explorer/hardhat-plugin.
Add a child contract. It has an immutable, so runtime code differs from deployedBytecode, which a fix has to handle.
Add an Ignition module that creates four children and registers two of them with m.contractAt.
ignition/modules/GriffinStack.ts:
import{buildModule}from"@nomicfoundation/hardhat-ignition/modules";constSALT=`0x${"11".repeat(32)}`;exportdefaultbuildModule("GriffinStack",(m)=>{constforge=m.contract("DragonForge");// Created by the factory, never registered with Ignitionm.call(forge,"forge",["Ember"],{id: "forgeEmber"});// CREATEm.call(forge,"forgeDeterministic",["Ash",SALT],{id: "forgeAsh"});// CREATE2// Created by the factory, then registered with m.contractAtconstfrost=m.call(forge,"forge",["Frost"],{id: "forgeFrost"});m.contractAt("DragonToken",m.readEventArgument(frost,"DragonForged","token"));// id: GriffinStack#DragonTokenconststorm=m.call(forge,"forge",["Storm"],{id: "forgeStorm"});m.contractAt("DragonToken",m.readEventArgument(storm,"DragonForged","token"),{id: "StormToken",// a second contractAt of the same contract needs its own id});return{ forge };});
Start a fresh node (the explorer comes up on :3030) and deploy:
Open http://localhost:3030/#/31337/address/<address> for each one. Without a browser, you can dump the address → contract map the plugin injects into the page instead:
curl -s http://localhost:3030/ | node -e ' const html = require("fs").readFileSync(0, "utf8"); const head = "localStorage.setItem(\"OPENSCAN_ARTIFACTS_JSON_V1\","; const start = html.indexOf(head); if (start === -1) { console.log("no artifacts injected"); process.exit(); } const end = html.indexOf(")}catch(e){console.warn(\"[openscan]", start); const map = JSON.parse(JSON.parse(html.slice(start + head.length, end))); for (const [address, a] of Object.entries(map)) console.log(address, a.contractName);'
Expected Behavior
Every DragonToken is labeled from its local artifact: contract name, decoded ABI, source. This holds however it got on chain (top-level deploy, created by a factory, or registered with m.contractAt) and whatever its Ignition future id is.
Actual Behavior
Following the plugin 1.3.1 code paths described under Cause for each contract:
Contract
How it got on chain
In deployed_addresses.json
Labeled
DragonForge
top-level deploy
GriffinStack#DragonForge
✅
DragonToken "Ember"
forge → CREATE
—
❌
DragonToken "Ash"
forgeDeterministic → CREATE2
—
❌
DragonToken "Frost"
CREATE, then m.contractAt with the default id
GriffinStack#DragonToken
✅
DragonToken "Storm"
CREATE, then m.contractAt with id: "StormToken"
GriffinStack#StormToken
❌
The unlabeled contracts show as unverified, and calls to them can't be decoded from a local artifact. On Hardhat, the transaction analyser's call tree shows each CREATE/CREATE2 frame without the created contract's address.
Cause
Plugin paths are relative to packages/plugin/ in openscan-explorer/hardhat-plugin (v1.3.1). Explorer paths are relative to this repo (main @ a1cccee).
How a local contract gets labeled today
The plugin runs inside the hardhat node process. On every request for index.html it builds an address → artifact map and inlines it into localStorage["OPENSCAN_ARTIFACTS_JSON_V1"] (src/services/webapp.ts, injectArtifactsScript, ~lines 203–234).
The explorer looks artifacts up by address only: jsonFiles[addressHash.toLowerCase()] in src/components/pages/evm/address/displays/ContractDisplay.tsx (~line 117), AddressDetails.tsx (~134), ERC20Display.tsx (~163) and ERC721Display.tsx (~141). Then hasVerifiedContract = isVerified || !!parsedLocalData (ContractDisplay.tsx ~157). A local chain has no Sourcify fallback, so an address missing from the map is unverified.
The map has exactly two sources, merged in src/server.ts (~lines 60–65):
Bytecode tracker.DeploymentTracker (src/deployment-tracker.ts) covers raw deploy scripts.
Why a factory-created contract is in neither
Not in Ignition's files. Ignition records addresses only for what it deploys itself (m.contract, m.library) or is told about (m.contractAt). See @nomicfoundation/ignition-core 3.1.7, internal/views/find-deployed-contracts.js. A contract created inside forge() is a side effect of an m.call, so it never reaches deployed_addresses.json.
Invisible to the tracker. The tracker starts on an eth_sendTransaction with no to (src/hooks/network.ts ~line 89). It completes only when a receipt carries contractAddress (~lines 151–154). A factory call has to = factory and contractAddress = null. The child's creation code is never the data of any transaction; it only runs inside the factory's execution. So prefix-matching creation bytecode (findMatchingArtifact, src/deployment-tracker.ts ~lines 128–141) cannot see it, even in principle.
The tracker also reads only artifacts/contracts/**, and only once, when the explorer starts (constructor, ~lines 25–37). Children defined elsewhere (e.g. test/, mocks/ outside contracts/) or compiled after hardhat node started are unknown to it.
m.contractAt doesn't work around it
Registering the child does put everything on disk. Ignition writes its address to deployed_addresses.json under the future id, and saves its artifact as artifacts/<futureId>.json (ignition-core 3.1.7: save-artifacts-for-future.js handles NAMED_ARTIFACT_CONTRACT_AT; file-deployment-loader.js ~line 43).
The plugin loses it when pairing the two (src/artifacts.ts, loadArtifacts, ~lines 64–104). It keys each address by whatever follows # in the future id, then looks the address up by the artifact's contractName:
constcontractName=moduleContract.split("#")[1];// "StormToken"contractDeployments[contractName]=address;// ...constdeployedAddress=contractDeployments[artifact.contractName];// looks up "DragonToken"
That works only when the future id ends in the contract name:
Custom id (GriffinStack#StormToken): the address is keyed StormToken, but the artifact's contractName is DragonToken. The lookup misses and the contract is dropped. Ignition requires a custom id for a second contractAt of the same contract in one module, so at most one instance per contract name can ever be labeled.
Same contract name in two modules (GriffinStack#RunestoneVault, WyvernStack#RunestoneVault): both addresses land on the key RunestoneVault and the later one overwrites the earlier. Both artifacts then resolve to the same address.
Related explorer gap
On Hardhat, the analyser builds its call tree from struct logs (src/services/adapters/HardhatAdapter/HardhatAdapter.ts, getAnalyserCallTrace, ~lines 288–307). buildCallTreeFromStructLogs sets to = undefined for every CREATE/CREATE2 frame (src/utils/structLogConverter.ts ~lines 148–165). src/components/pages/evm/tx/analyser/CallTreeTab.tsx renders an address link only when node.to is set (~lines 80–83). So even with an artifact, the child can't be reached from the transaction that created it.
The address is recoverable: it is the top stack item of the first struct log back at the parent's depth after the CREATE frame returns (0 if creation failed).
Suggested Fix
Identify local contracts by what is on chain, not by how they were deployed.
1. Match runtime bytecode, not creation bytecode. For a candidate address, fetch eth_getCode and compare it with each artifact's deployedBytecode (both Hardhat 3 and Ignition artifacts carry deployedBytecode, immutableReferences and deployedLinkReferences):
Bucket artifacts by bytecode length.
Before comparing, zero the on-chain bytes at the artifact's immutableReferences ranges (solc leaves zeros there in deployedBytecode). Treat deployedLinkReferences ranges as wildcards.
If nothing matches exactly, retry ignoring the trailing CBOR metadata (a partial match, in Sourcify's terms).
Scan every artifact under artifacts/, not just artifacts/contracts/. Rescan on a miss, so contracts compiled after hardhat node started still match.
Minimal proxies (EIP-1167 clones) are out of scope: their runtime code is the proxy, not the implementation.
2. Find candidate addresses. Either option reuses step 1.
(a) Plugin only: trace new transactions.
Pass the NetworkConnection from the newConnection hook into createOpenscanServer, and keep a cursor of the last scanned block.
When index.html is requested, walk new blocks. Run debug_traceTransaction on each transaction; EDR supports the default struct-log tracer, and the explorer's Hardhat adapter already relies on it.
Collect addresses produced by CREATE/CREATE2 frames at any depth, plus receipt contractAddress. Match each one with step 1.
Results join the existing injected map, so the explorer needs no change.
Costs: one trace per transaction, once. artifactLoader becomes async. Requests sent through the hooked provider trigger the plugin's own onRequest link logging, so skip logging for plugin-originated requests.
(b) Plugin + explorer: look up on demand.
Add an endpoint to the plugin server (e.g. GET /api/artifacts/:address) that runs step 1 for one address.
The explorer calls it when jsonFiles[address] misses on the plugin's network. It already fetches eth_getCode for every address page (src/utils/addressTypeDetection.ts ~line 48).
This has to be explorer-driven: with hash routing (/#/31337/address/0x…), the server never learns which address is open.
(a) is the smaller, plugin-only change and labels contracts before anyone opens them. (b) is cheaper per request and is the better long-term shape if #402 moves artifacts to an endpoint.
3. Pair Ignition entries by future id. Separately from the above, loadArtifacts should iterate deployed_addresses.json and read artifacts/<futureId>.json for each entry (Ignition already names artifact files by future id), instead of pairing by contract name. That alone fixes the m.contractAt workaround and the same-name-in-two-modules collision.
4. Explorer call tree. In buildCallTreeFromStructLogs, fill to for CREATE/CREATE2 frames from the stack once the frame returns, so the call tree links the created contract.
Acceptance Criteria
With the repro above, DragonForge and all four DragonTokens show name, ABI and source.
Works for CREATE and CREATE2, including a child created by a contract that was itself created by a factory (nested).
Works for contracts with immutables (DragonToken.forge) and with linked libraries.
Works for a child whose source lives outside contracts/ (e.g. test/), and for one compiled after hardhat node started.
Two contractAts of the same contract (custom ids) and the same contract name in two Ignition modules are all labeled with their own addresses.
On Hardhat, the analyser's call tree shows the created address for CREATE/CREATE2 frames.
No regression for top-level Ignition deploys and raw deploy scripts.
Environment
Plugin: @openscan/hardhat-plugin 1.3.1, with @openscan/explorer 1.2.5-alpha
Description
@openscan/hardhat-pluginlabels a contract (name, ABI, source) only when a top-level transaction deployed it. A contract created inside a transaction is never matched to its artifact, even though that artifact is compiled in the project. That covers a factory callingnew Child()(CREATE) ornew Child{salt: s}()(CREATE2). The explorer shows such a contract as unverified: no name, no decoded ABI, no source.Factories are a common deployment pattern (token launchers, pool and vault factories). On a local chain, every contract they produce is opaque.
The obvious workaround is to register each child in the Ignition module with
m.contractAt. It works for only one instance per contract name, because the plugin pairs Ignition entries with artifacts by contract name rather than by future id. Seem.contractAtdoesn't work around it below.Steps to Reproduce
Any Hardhat 3 project with the plugin enabled works, e.g.
packages/example-projectinopenscan-explorer/hardhat-plugin.Add a child contract. It has an immutable, so runtime code differs from
deployedBytecode, which a fix has to handle.contracts/core/DragonToken.sol:Add a factory.
contracts/core/DragonForge.sol:Add an Ignition module that creates four children and registers two of them with
m.contractAt.ignition/modules/GriffinStack.ts:Start a fresh node (the explorer comes up on
:3030) and deploy:List the four
DragonTokenaddresses from the factory's events.FrostandStormare the two that also appear indeployed_addresses.json.Open
http://localhost:3030/#/31337/address/<address>for each one. Without a browser, you can dump the address → contract map the plugin injects into the page instead:Expected Behavior
Every
DragonTokenis labeled from its local artifact: contract name, decoded ABI, source. This holds however it got on chain (top-level deploy, created by a factory, or registered withm.contractAt) and whatever its Ignition future id is.Actual Behavior
Following the plugin 1.3.1 code paths described under Cause for each contract:
deployed_addresses.jsonDragonForgeGriffinStack#DragonForgeDragonToken"Ember"forge→ CREATEDragonToken"Ash"forgeDeterministic→ CREATE2DragonToken"Frost"m.contractAtwith the default idGriffinStack#DragonTokenDragonToken"Storm"m.contractAtwithid: "StormToken"GriffinStack#StormTokenThe unlabeled contracts show as unverified, and calls to them can't be decoded from a local artifact. On Hardhat, the transaction analyser's call tree shows each CREATE/CREATE2 frame without the created contract's address.
Cause
Plugin paths are relative to
packages/plugin/inopenscan-explorer/hardhat-plugin(v1.3.1). Explorer paths are relative to this repo (main@a1cccee).How a local contract gets labeled today
The plugin runs inside the
hardhat nodeprocess. On every request forindex.htmlit builds an address → artifact map and inlines it intolocalStorage["OPENSCAN_ARTIFACTS_JSON_V1"](src/services/webapp.ts,injectArtifactsScript, ~lines 203–234).The explorer looks artifacts up by address only:
jsonFiles[addressHash.toLowerCase()]insrc/components/pages/evm/address/displays/ContractDisplay.tsx(~line 117),AddressDetails.tsx(~134),ERC20Display.tsx(~163) andERC721Display.tsx(~141). ThenhasVerifiedContract = isVerified || !!parsedLocalData(ContractDisplay.tsx~157). A local chain has no Sourcify fallback, so an address missing from the map is unverified.The map has exactly two sources, merged in
src/server.ts(~lines 60–65):loadIgnitionArtifacts(src/artifacts.ts) readsignition/deployments/chain-31337/.DeploymentTracker(src/deployment-tracker.ts) covers raw deploy scripts.Why a factory-created contract is in neither
m.contract,m.library) or is told about (m.contractAt). See@nomicfoundation/ignition-core3.1.7,internal/views/find-deployed-contracts.js. A contract created insideforge()is a side effect of anm.call, so it never reachesdeployed_addresses.json.eth_sendTransactionwith noto(src/hooks/network.ts~line 89). It completes only when a receipt carriescontractAddress(~lines 151–154). A factory call hasto = factoryandcontractAddress = null. The child's creation code is never the data of any transaction; it only runs inside the factory's execution. So prefix-matching creation bytecode (findMatchingArtifact,src/deployment-tracker.ts~lines 128–141) cannot see it, even in principle.The tracker also reads only
artifacts/contracts/**, and only once, when the explorer starts (constructor, ~lines 25–37). Children defined elsewhere (e.g.test/,mocks/outsidecontracts/) or compiled afterhardhat nodestarted are unknown to it.m.contractAtdoesn't work around itRegistering the child does put everything on disk. Ignition writes its address to
deployed_addresses.jsonunder the future id, and saves its artifact asartifacts/<futureId>.json(ignition-core3.1.7:save-artifacts-for-future.jshandlesNAMED_ARTIFACT_CONTRACT_AT;file-deployment-loader.js~line 43).The plugin loses it when pairing the two (
src/artifacts.ts,loadArtifacts, ~lines 64–104). It keys each address by whatever follows#in the future id, then looks the address up by the artifact'scontractName:That works only when the future id ends in the contract name:
id(GriffinStack#StormToken): the address is keyedStormToken, but the artifact'scontractNameisDragonToken. The lookup misses and the contract is dropped. Ignition requires a custom id for a secondcontractAtof the same contract in one module, so at most one instance per contract name can ever be labeled.GriffinStack#RunestoneVault,WyvernStack#RunestoneVault): both addresses land on the keyRunestoneVaultand the later one overwrites the earlier. Both artifacts then resolve to the same address.Related explorer gap
On Hardhat, the analyser builds its call tree from struct logs (
src/services/adapters/HardhatAdapter/HardhatAdapter.ts,getAnalyserCallTrace, ~lines 288–307).buildCallTreeFromStructLogssetsto = undefinedfor every CREATE/CREATE2 frame (src/utils/structLogConverter.ts~lines 148–165).src/components/pages/evm/tx/analyser/CallTreeTab.tsxrenders an address link only whennode.tois set (~lines 80–83). So even with an artifact, the child can't be reached from the transaction that created it.The address is recoverable: it is the top stack item of the first struct log back at the parent's depth after the CREATE frame returns (
0if creation failed).Suggested Fix
Identify local contracts by what is on chain, not by how they were deployed.
1. Match runtime bytecode, not creation bytecode. For a candidate address, fetch
eth_getCodeand compare it with each artifact'sdeployedBytecode(both Hardhat 3 and Ignition artifacts carrydeployedBytecode,immutableReferencesanddeployedLinkReferences):immutableReferencesranges (solc leaves zeros there indeployedBytecode). TreatdeployedLinkReferencesranges as wildcards.artifacts/, not justartifacts/contracts/. Rescan on a miss, so contracts compiled afterhardhat nodestarted still match.2. Find candidate addresses. Either option reuses step 1.
NetworkConnectionfrom thenewConnectionhook intocreateOpenscanServer, and keep a cursor of the last scanned block.index.htmlis requested, walk new blocks. Rundebug_traceTransactionon each transaction; EDR supports the default struct-log tracer, and the explorer's Hardhat adapter already relies on it.contractAddress. Match each one with step 1.artifactLoaderbecomes async. Requests sent through the hooked provider trigger the plugin's ownonRequestlink logging, so skip logging for plugin-originated requests.GET /api/artifacts/:address) that runs step 1 for one address.jsonFiles[address]misses on the plugin's network. It already fetcheseth_getCodefor every address page (src/utils/addressTypeDetection.ts~line 48)./#/31337/address/0x…), the server never learns which address is open.localStorage, which [BUG] hardhat-plugin: artifacts exceed the localStorage quota, so no local contract shows as verified #402 suggests for the quota anyway.(a) is the smaller, plugin-only change and labels contracts before anyone opens them. (b) is cheaper per request and is the better long-term shape if #402 moves artifacts to an endpoint.
3. Pair Ignition entries by future id. Separately from the above,
loadArtifactsshould iteratedeployed_addresses.jsonand readartifacts/<futureId>.jsonfor each entry (Ignition already names artifact files by future id), instead of pairing by contract name. That alone fixes them.contractAtworkaround and the same-name-in-two-modules collision.4. Explorer call tree. In
buildCallTreeFromStructLogs, filltofor CREATE/CREATE2 frames from the stack once the frame returns, so the call tree links the created contract.Acceptance Criteria
DragonForgeand all fourDragonTokens show name, ABI and source.DragonToken.forge) and with linked libraries.contracts/(e.g.test/), and for one compiled afterhardhat nodestarted.contractAts of the same contract (custom ids) and the same contract name in two Ignition modules are all labeled with their own addresses.Environment
@openscan/hardhat-plugin1.3.1, with@openscan/explorer1.2.5-alphamain@a1cccee@nomicfoundation/ignition-core3.1.7, Solidity 0.8.29Additional Context
Related: #146 (artifact auto-injection), #399 (source resolution), #402 (localStorage quota: every newly labeled contract adds to the injected payload, and option 2(b) avoids the quota altogether).