Description
The Hardhat plugin (@openscan/hardhat-plugin) hands contract artifacts to the explorer by inlining a <script> into index.html that writes them to localStorage["OPENSCAN_ARTIFACTS_JSON_V1"]. Every artifact carries its entire build-info — the full solc standard-JSON input, with every source of the compilation job — and contracts compiled in the same job each carry their own copy of that same file.
On a project of realistic size the payload outgrows the browser's localStorage quota. setItem throws QuotaExceededError, the inline catch reduces it to a console.warn, and the explorer finds no local artifact for any address. A local chain has no Sourcify/Etherscan fallback, so every deployed contract shows as unverified — no decoded ABI, no source — and nothing in the terminal hints at a problem.
Steps to Reproduce
- Create a Hardhat 3 project whose compilation job holds ~100 sources — e.g. a few contracts importing OpenZeppelin, plus
forge-std and Solidity tests. The default build profile compiles them all in one job, so they share a single build-info.
- With
hardhat node running and the plugin enabled, deploy four contracts with Hardhat Ignition (module GriffinStack: DragonToken, RunestoneVault, WyvernRegistry, MockGoldCoin).
- Open
http://localhost:3030 and navigate to any of the deployed contracts.
Expected Behavior
Each contract is shown as verified from its local artifact: decoded ABI, source view, compiler metadata.
Actual Behavior
Every contract is shown as unverified. Measuring the payload inlined into the served index.html:
|
Size |
Build-info (input only), repeated once per contract |
~1.4M chars |
| Contract source text, per contract |
1–20 KB |
Total OPENSCAN_ARTIFACTS_JSON_V1 value (4 contracts) |
~5.6M chars |
~99% of the payload is the same build-info repeated four times. The value contains non-Latin-1 characters, so Chromium stores it as UTF-16: ~10.6 MiB against a ~10 MiB per-origin quota. Firefox and Safari cap localStorage at ~5 MB, lower still.
Those characters are not exotic: OpenZeppelin's utils/math/Math.sol alone has ~90 in its NatSpec (≤, ε, ≡, superscripts), and it is pulled in by Strings.sol → MessageHashUtils.sol → EIP712.sol; forge-std's Test.sol and Vm.sol add a few more. In practice any project using OpenZeppelin hits the UTF-16 case.
A failed write also leaves the previous value in place. After an earlier, smaller deploy, the explorer can therefore show stale artifacts — and Hardhat's deterministic deployer addresses make address reuse likely.
Cause
@openscan/hardhat-plugin v1.3.1:
-
src/artifacts.ts (~line 122) and src/deployment-tracker.ts (~line 108) attach the whole parsed build-info to each artifact:
artifactData.buildInfo = JSON.parse(readFileSync(buildInfoPath, "utf-8"));
-
src/services/webapp.ts (injectArtifactsScript, ~lines 203–230) serialises everything into a single setItem and swallows the failure:
`try{localStorage.setItem("OPENSCAN_ARTIFACTS_JSON_V1",${safeJsonString})}` +
`catch(e){console.warn("[openscan] Failed to inject artifacts:",e)}`
Yet the explorer reads only four scalar fields of buildInfo — solcVersion, solcLongVersion, input.language and input.settings.evmVersion. See src/components/pages/evm/address/displays/ContractDisplay.tsx (~lines 124–139); AddressDetails.tsx, ERC721Display.tsx, ERC1155Display.tsx, X402FacilitatorDisplay.tsx and TransactionDisplay.tsx follow the same pattern. The source view uses sourceCode, not input.sources. And because hasVerifiedContract = isVerified || !!parsedLocalData, a missing local artifact on a local chain always means "unverified".
Suggested Fix
In the plugin, once resolveSourceCode has run (its fallback added for #399 still needs input.sources), reduce buildInfo to what the explorer consumes:
artifactData.buildInfo = {
solcVersion: buildInfo.solcVersion,
solcLongVersion: buildInfo.solcLongVersion,
input: {
language: buildInfo.input?.language,
settings: { evmVersion: buildInfo.input?.settings?.evmVersion },
},
};
Apply the same change in src/deployment-tracker.ts. That takes the payload from megabytes to tens of kilobytes, regardless of how many contracts are deployed or how large the compilation job is.
Two smaller hardening points, worth doing regardless:
- Make the failure loud. Warn in the terminal when the serialised payload approaches the quota, and have the inline script log the error
name, so QuotaExceededError is recognisable at a glance.
- Don't leave stale data behind. Call
removeItem before setItem, so a failed write empties the key instead of leaving artifacts from a previous deploy mapped onto reused addresses.
If the explorer ever needs the full standard-JSON input (e.g. a multi-file source view), localStorage is the wrong transport. Serving artifacts from an endpoint on the plugin's server, or storing them in IndexedDB, avoids the quota altogether.
Environment
- Plugin:
@openscan/hardhat-plugin 1.3.1 (latest published), with @openscan/explorer 1.2.5-alpha
- Hardhat: 3.9.0, Solidity 0.8.29, deployed via Hardhat Ignition 3.1.7
- Browser: Brave 1.94 (Chromium 152) — the payload also exceeds Firefox's and Safari's ~5 MB limit
- OS: macOS 26
- Network: Localhost (chain id 31337)
Additional Context
Related: #146 (artifact auto-injection), #399 (source resolution — its build-info fallback is why the trim must happen after source resolution).
Happy to open a PR against the plugin with the trim in both files, if that would help.
Description
The Hardhat plugin (
@openscan/hardhat-plugin) hands contract artifacts to the explorer by inlining a<script>intoindex.htmlthat writes them tolocalStorage["OPENSCAN_ARTIFACTS_JSON_V1"]. Every artifact carries its entire build-info — the full solc standard-JSON input, with every source of the compilation job — and contracts compiled in the same job each carry their own copy of that same file.On a project of realistic size the payload outgrows the browser's
localStoragequota.setItemthrowsQuotaExceededError, the inlinecatchreduces it to aconsole.warn, and the explorer finds no local artifact for any address. A local chain has no Sourcify/Etherscan fallback, so every deployed contract shows as unverified — no decoded ABI, no source — and nothing in the terminal hints at a problem.Steps to Reproduce
forge-stdand Solidity tests. The default build profile compiles them all in one job, so they share a single build-info.hardhat noderunning and the plugin enabled, deploy four contracts with Hardhat Ignition (moduleGriffinStack:DragonToken,RunestoneVault,WyvernRegistry,MockGoldCoin).http://localhost:3030and navigate to any of the deployed contracts.Expected Behavior
Each contract is shown as verified from its local artifact: decoded ABI, source view, compiler metadata.
Actual Behavior
Every contract is shown as unverified. Measuring the payload inlined into the served
index.html:inputonly), repeated once per contractOPENSCAN_ARTIFACTS_JSON_V1value (4 contracts)~99% of the payload is the same build-info repeated four times. The value contains non-Latin-1 characters, so Chromium stores it as UTF-16: ~10.6 MiB against a ~10 MiB per-origin quota. Firefox and Safari cap
localStorageat ~5 MB, lower still.Those characters are not exotic: OpenZeppelin's
utils/math/Math.solalone has ~90 in its NatSpec (≤,ε,≡, superscripts), and it is pulled in byStrings.sol→MessageHashUtils.sol→EIP712.sol; forge-std'sTest.solandVm.soladd a few more. In practice any project using OpenZeppelin hits the UTF-16 case.A failed write also leaves the previous value in place. After an earlier, smaller deploy, the explorer can therefore show stale artifacts — and Hardhat's deterministic deployer addresses make address reuse likely.
Cause
@openscan/hardhat-pluginv1.3.1:src/artifacts.ts(~line 122) andsrc/deployment-tracker.ts(~line 108) attach the whole parsed build-info to each artifact:src/services/webapp.ts(injectArtifactsScript, ~lines 203–230) serialises everything into a singlesetItemand swallows the failure:Yet the explorer reads only four scalar fields of
buildInfo—solcVersion,solcLongVersion,input.languageandinput.settings.evmVersion. Seesrc/components/pages/evm/address/displays/ContractDisplay.tsx(~lines 124–139);AddressDetails.tsx,ERC721Display.tsx,ERC1155Display.tsx,X402FacilitatorDisplay.tsxandTransactionDisplay.tsxfollow the same pattern. The source view usessourceCode, notinput.sources. And becausehasVerifiedContract = isVerified || !!parsedLocalData, a missing local artifact on a local chain always means "unverified".Suggested Fix
In the plugin, once
resolveSourceCodehas run (its fallback added for #399 still needsinput.sources), reducebuildInfoto what the explorer consumes:Apply the same change in
src/deployment-tracker.ts. That takes the payload from megabytes to tens of kilobytes, regardless of how many contracts are deployed or how large the compilation job is.Two smaller hardening points, worth doing regardless:
name, soQuotaExceededErroris recognisable at a glance.removeItembeforesetItem, so a failed write empties the key instead of leaving artifacts from a previous deploy mapped onto reused addresses.If the explorer ever needs the full standard-JSON input (e.g. a multi-file source view),
localStorageis the wrong transport. Serving artifacts from an endpoint on the plugin's server, or storing them in IndexedDB, avoids the quota altogether.Environment
@openscan/hardhat-plugin1.3.1 (latest published), with@openscan/explorer1.2.5-alphaAdditional Context
Related: #146 (artifact auto-injection), #399 (source resolution — its build-info fallback is why the trim must happen after source resolution).
Happy to open a PR against the plugin with the trim in both files, if that would help.