Skip to content

[BUG] hardhat-plugin: contracts deployed by another contract are never labeled #403

Description

@MatiasOS

Description

@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.

  1. Add a child contract. It has an immutable, so runtime code differs from deployedBytecode, which a fix has to handle.

    contracts/core/DragonToken.sol:

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.28;
    
    contract DragonToken {
        string public name;
        address public immutable forge;
    
        constructor(string memory name_) {
            name = name_;
            forge = msg.sender;
        }
    }
  2. Add a factory.

    contracts/core/DragonForge.sol:

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.28;
    
    import {DragonToken} from "./DragonToken.sol";
    
    contract DragonForge {
        event DragonForged(address indexed token);
    
        function forge(string calldata tokenName) external returns (DragonToken token) {
            token = new DragonToken(tokenName);
            emit DragonForged(address(token));
        }
    
        function forgeDeterministic(string calldata tokenName, bytes32 salt) external returns (DragonToken token) {
            token = new DragonToken{salt: salt}(tokenName);
            emit DragonForged(address(token));
        }
    }
  3. 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";
    
    const SALT = `0x${"11".repeat(32)}`;
    
    export default buildModule("GriffinStack", (m) => {
      const forge = m.contract("DragonForge");
    
      // Created by the factory, never registered with Ignition
      m.call(forge, "forge", ["Ember"], { id: "forgeEmber" }); // CREATE
      m.call(forge, "forgeDeterministic", ["Ash", SALT], { id: "forgeAsh" }); // CREATE2
    
      // Created by the factory, then registered with m.contractAt
      const frost = m.call(forge, "forge", ["Frost"], { id: "forgeFrost" });
      m.contractAt("DragonToken", m.readEventArgument(frost, "DragonForged", "token")); // id: GriffinStack#DragonToken
    
      const storm = 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 };
    });
  4. Start a fresh node (the explorer comes up on :3030) and deploy:

    npx hardhat node                                                                    # terminal 1
    npx hardhat ignition deploy ignition/modules/GriffinStack.ts --network localhost    # terminal 2
  5. List the four DragonToken addresses from the factory's events. Frost and Storm are the two that also appear in deployed_addresses.json.

    FORGE=$(node -p 'require("./ignition/deployments/chain-31337/deployed_addresses.json")["GriffinStack#DragonForge"]')
    curl -s http://127.0.0.1:8545 -H 'content-type: application/json' \
      --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getLogs\",\"params\":[{\"fromBlock\":\"0x0\",\"toBlock\":\"latest\",\"address\":\"$FORGE\"}]}" \
      | node -e 'for (const l of JSON.parse(require("fs").readFileSync(0, "utf8")).result) console.log("0x" + l.topics[1].slice(26))'
  6. 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):

  1. Ignition deployment files. loadIgnitionArtifacts (src/artifacts.ts) reads ignition/deployments/chain-31337/.
  2. 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:

const contractName = moduleContract.split("#")[1];     // "StormToken"
contractDeployments[contractName] = address;
// ...
const deployedAddress = 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.
    • No tracing is needed, and it moves artifacts off 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, 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
  • Explorer source checked: main @ a1cccee
  • Hardhat: 3.9.0, @nomicfoundation/ignition-core 3.1.7, Solidity 0.8.29
  • Browser: any (the artifact map is built server-side)
  • OS: macOS 26
  • Network: Localhost (chain id 31337)

Additional 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions