Using it
npm install --save-dev arc-evm-lint
npx arc-evm-lint contracts/ scripts/
npx arc-evm-lint --rules # list every rule
npx arc-evm-lint --foundry # read foundry.toml
npx arc-evm-lint --sarif --out arc-lint.sarif # GitHub code scanning
npx arc-evm-lint --github # PR annotations
Published as arc-evm-lint: arc-lint on npm is
Egor Galkin's linter, which covers
the same Solidity hazards with solc AST analysis.
Bad.sol
17:59 error block.prevrandao is always 0 on Arc arc/no-prevrandao
│ uint256 seed = uint256(keccak256(abi.encodePacked(block.prevrandao, …)));
→ Arc has no beacon-chain RANDAO mix, so block.prevrandao is hardcoded
to 0. Any contract deriving randomness from it is fully predictable.
Use an oracle or VRF instead.
https://docs.arc.io/arc/references/evm-differences
9 error · 3 warning across 2 of 3 file(s)
Exit code is 1 on any error-severity finding, so it drops into CI as-is. Use
--fail-on warning to tighten that, or --fail-on never to
report without blocking.
The rules
Each maps to a documented protocol-level divergence, and carries the doc reference that justifies it — so a finding can always be traced back to the spec.
| Rule | Severity | Catches |
|---|---|---|
arc/decimals-mix | error | An 18-decimal literal in a file that also uses the 6-decimal ERC-20 USDC interface |
arc/no-prevrandao | error | block.prevrandao / block.difficulty, both hardcoded to 0 |
arc/no-assembly-prevrandao | error | The same read from inline assembly |
arc/no-blob-opcodes | error | blobhash, blobbasefee — blob transactions are rejected |
arc/no-beacon-roots | error | EIP-4788 beacon roots; the contract is not deployed on Arc |
arc/burn-to-zero-address | error | Native value sent to address(0), which reverts |
arc/gas-fee-floor | error | maxFeePerGas below Arc's 20 Gwei floor |
arc/wrong-usdc-decimals | error | parseEther / parseUnits(x, 18) on a 6-decimal ERC-20 call |
arc/selfdestruct-value-rules | warning | SELFDESTRUCT under Arc's extra native-value rules |
arc/balanceof-zero-is-not-empty | warning | balanceOf(x) == 0, which truncation makes unreliable |
arc/ether-unit-is-usdc | warning | The ether unit, which denominates USDC here |
arc/hardcoded-eth-rpc | warning | Localhost RPCs that cannot reproduce Arc semantics |
arc/unnecessary-weth-wrapper | info | A wrap/unwrap layer Arc does not need |
arc/prefer-multicall3from | info | Multicall3, where Arc's Multicall3From preserves msg.sender |
It runs where you already work
Foundry
Foundry has no plugin API, so integration means being a well-behaved CLI: read the
same config forge reads, lint exactly what it compiles, and skip the
dependency tree in libs.
npx arc-evm-lint --foundry
--foundry reads src, script, test,
and libs from foundry.toml, honouring
FOUNDRY_PROFILE.
Hardhat 3
// hardhat.config.ts
import arcLint from "hardhat-arc-lint";
export default { plugins: [arcLint] };
npx hardhat arc-lint
npx hardhat arc-lint --format sarif --out arc-lint.sarif
npx hardhat arc-lint --fail-on warning
The task reads Hardhat's own config.paths rather than guessing directory
names. Hardhat 3's paths has no entry for deploy
scripts — and the script-language rules fire almost exclusively there — so
scripts/, ignition/modules/, and deploy/ are
added when they exist, and the task says when it did that.
A lint failure throws HardhatPluginError, so it prints as a plugin
result rather than as a Hardhat crash with a stack trace and a bug-report link.
GitHub Actions
- uses: ilkermanap/arctools/packages/arc-lint@main
with:
foundry: "true"
format: sarif # or github (default) / text
fail-on: error
format: github annotates the pull request diff inline and writes a step
summary grouped by rule. format: sarif emits a SARIF 2.1.0 report for
github/codeql-action/upload-sarif, so findings land in the Security tab
as code scanning alerts. Step outputs expose errors,
warnings, findings, and sarif-file.
Suppression
// arc-lint-disable-next-line arc/decimals-mix
uint256 amount = 1e18;
Omit the rule id to mute every rule on that line, or use
arc-lint-disable-line for the current one. Whole paths go in
.arclintignore; a pattern containing * is anchored to the
full path, one without matches as a substring.
How it works, and what it cannot do
Comments and string literals are blanked in place — preserving byte offsets,
so line and column stay exact — and rules match against that. Solidity rules see
strings blanked, which keeps revert messages from tripping them. Script rules see
strings intact, because the values they check (parseGwei("5"), RPC URLs)
live inside strings. String literals are always parsed even when kept, so a URL
containing // is never mistaken for a comment.
Rules match against comment-stripped source rather than a compiled AST. That keeps
arc-lint compiler-free and instant, but it is coarse:
arc/decimals-mix flags every 1e18 in a file that touches
IERC20, instead of following one value across assignments. AST rules
via solc --standard-json are the next step.
Tests
33 tests, fully offline. Bad.sol is expected to trip ten rules exactly
once each and Good.sol must stay clean; the rest pin the SARIF and
annotation encodings and the foundry.toml reader.