Skip to content

fix(plugin): resolve contract sources from nested directories - #11

Merged
MatiasOS merged 3 commits into
openscan-explorer:mainfrom
MatiasOS:issue-399-nested-contract-sources
Sep 10, 2026
Merged

fix(plugin): resolve contract sources from nested directories#11
MatiasOS merged 3 commits into
openscan-explorer:mainfrom
MatiasOS:issue-399-nested-contract-sources

Conversation

@MatiasOS

Copy link
Copy Markdown
Member

Fixes openscan-explorer/explorer#399

Problem

Source lookup took the basename of sourceName and rejoined it to a hardcoded <projectRoot>/contracts/. Since sourceName is already project-relative, a contract at contracts/core/Counter.sol was looked for at contracts/Counter.sol.

Every contract in a project with subdirectories got no source, and a contract compiled from outside contracts/ could never resolve at all. existsSync failed silently, so contract pages rendered with a working ABI and build info but an empty source view — reading as a partial failure rather than a path bug.

Both code paths shared the construct: artifacts.ts (Ignition deployments) and deployment-tracker.ts (raw eth_sendTransaction deploys).

Fix

sourceName is already project-relative, so it is now joined as-is — correct for flat, nested, and outside-contracts/ layouts alike.

When the file is not on disk, the source text is recovered from the solc standard JSON input that already ships in the build info alongside every artifact. Hardhat 3 artifacts carry an inputSourceName that is exactly the key into input.sources, with userSourceNameMap in the build info as a second route, so this is an exact lookup rather than a heuristic:

// artifact
{ "sourceName": "contracts/core/Counter.sol",
  "inputSourceName": "project/contracts/core/Counter.sol" }

// build info
{ "userSourceNameMap": { "contracts/core/Counter.sol": "project/contracts/core/Counter.sol" },
  "input": { "sources": { "project/contracts/core/Counter.sol": { "content": "" } } } }

That covers the one case the path fix alone cannot: a sourceName that is not a project-relative path at all, such as a directly deployed npm dependency (npm/@openzeppelin/contracts@5.x/proxy/ERC1967/ERC1967Proxy.sol).

Resolution now lives in one place, src/source-resolver.ts, used by both call sites. A miss warns once per source name — the artifact loader re-reads the deployment on every page request, so an ungated warning would repeat on every refresh.

deployment-tracker.ts loaded build info after source; those blocks are swapped, since the fallback depends on it.

Example project

packages/example-project kept a single flat contracts/Counter.sol — the one layout where the old lookup happened to be correct, which is why this went unnoticed. Counter moves to contracts/core/ and a TestToken is added in contracts/mocks/, deployed together from the Ignition module, so both deploy paths are exercised against more than one subdirectory.

The ignore is narrowed from /ignition to /ignition/deployments. Only the deployment state is regenerated per run; ignition/modules/Counter.ts is source, and the README's documented deploy step references it, so it should be in the repo.

Verification

Ran the node and explorer and inspected the artifact payload the plugin injects into the page, rather than eyeballing the UI:

Address Contract ABI buildInfo sourceCode
0x5fbd… Counter (contracts/core/) 4 present 364 chars
0xe7f1… TestToken (contracts/mocks/) 8 present 1030 chars
0xcf7e… Counter (raw deploy, DeploymentTracker) 4 present 364 chars

Also confirmed against the same fixture:

  • the previous logic MISSes both nested paths, the new one HITs
  • build-info fallback serves the source with the file renamed away, and stays quiet because it resolved
  • with both the file and build info unavailable, exactly one warning is logged across four page loads
  • flat, outside-contracts/, and npm-style sourceName all resolve; unknown keys, absent, malformed and null build info all return undefined without throwing
  • pnpm build and pnpm lint pass; Counter.t.sol's 3 tests still pass after the move

Not included

projectRoot is process.cwd() in both entry points (server.ts, hooks/network.ts) rather than hre.config.paths.root, and paths.sources is ignored, so a project that configures a non-default sources directory or is driven from another working directory still resolves against cwd. This fix is correct whenever cwd is the project root, which is the normal case; making it independent of cwd is a separate change and I'm happy to open a follow-up issue.

No tests are added here — the plugin currently has no test script at all, so root pnpm test is a no-op and CI's Test step does nothing. Restoring that harness felt like it belonged in its own PR; say the word and I'll add it with the nested fixtures checked in as JSON.

Source lookup took the basename of sourceName and rejoined it to a
hardcoded contracts/, so a contract at contracts/core/Counter.sol was
looked for at contracts/Counter.sol. Every contract in a project with
subdirectories got no source, and a contract compiled from outside
contracts/ could never resolve at all. existsSync failed silently, so
pages rendered with a working ABI and an empty source view.

sourceName is already project-relative, so join it as-is. When the file
is not on disk - moved or deleted since compilation, or an npm
dependency rather than a project file - recover the text from the solc
standard JSON input already shipped in the build info, keyed by the
artifact's inputSourceName or the build info's userSourceNameMap.

Both call sites shared the construct, so the resolution lives in one
place. A miss now warns once per source name: the artifact loader
re-reads the deployment on every page request, so an ungated warning
would repeat on every refresh.
The example project kept a single flat contracts/Counter.sol - the one
layout where the old basename-based source lookup happened to be
correct, which is why the bug went unnoticed. Move Counter into
contracts/core/ and add a TestToken in contracts/mocks/, deployed from
the Ignition module, so both the Ignition and raw-deploy paths are
exercised against more than one subdirectory.

Narrow the ignore from /ignition to /ignition/deployments. Only the
deployment state is regenerated per run; the module is source, and the
README's documented deploy step references it.
@MatiasOS
MatiasOS requested a review from AugustoL September 10, 2026 14:39
@MatiasOS
MatiasOS merged commit c9b2c02 into openscan-explorer:main Sep 10, 2026
1 check passed
@MatiasOS
MatiasOS deleted the issue-399-nested-contract-sources branch September 10, 2026 15:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Contract source never loads when contracts/ has subdirectories

1 participant