Verifying a contract means giving Etherscan your source code and the exact compiler settings you used. Etherscan recompiles it, and if the result matches the bytecode on-chain, it publishes the source next to the address. When verification fails with "bytecode does not match", your source is almost never wrong. One of the settings is.
Why verify at all
Unverified contracts are just hex. Verification gives you:
- Readable source on the Contract tab, so users and auditors can check what the code does.
- Read Contract / Write Contract tabs, which let anyone call functions from the browser.
- Decoded transactions and events: function names and arguments instead of raw calldata.
- Better tooling:
cast run --etherscan-api-key, Tenderly and wallets can show names instead of selectors.
Many users and protocols simply won't interact with an unverified contract.
Get an Etherscan API key
Create a free account on etherscan.io and generate an API key. Etherscan's V2 API is multichain: one key covers Ethereum, its testnets and the other Etherscan-family explorers, with the chain chosen by a chainid parameter. The older per-explorer V1 API has been deprecated, so if your tools complain about V1, upgrade them. Check Etherscan's current plan details, since the free tier doesn't cover every chain.
Keep the key in an environment variable:
export ETHERSCAN_API_KEY=your_key_here
Verify with Foundry
The simplest path is to verify at deploy time. Note that forge create only sends the transaction when you pass --broadcast, and --constructor-args must come last:
forge create src/MyToken.sol:MyToken \
--rpc-url $SEPOLIA_RPC_URL \
--account deployer \
--broadcast \
--verify \
--etherscan-api-key $ETHERSCAN_API_KEY \
--constructor-args "My Token" "MTK" 1000000000000000000000000
forge script ... --broadcast --verify works the same way for scripted deployments.
To verify something already deployed, use forge verify-contract. Foundry reads the compiler version, optimizer, via_ir and EVM version from foundry.toml, so run it from the same project and commit that built the contract:
forge verify-contract \
--chain sepolia \
--etherscan-api-key $ETHERSCAN_API_KEY \
--watch \
--constructor-args $(cast abi-encode "constructor(string,string,uint256)" "My Token" "MTK" 1000000000000000000000000) \
0xYourContractAddress \
src/MyToken.sol:MyToken
--watch polls until Etherscan reports a result. If you've lost the exact arguments, --guess-constructor-args together with --rpc-url tries to recover them from the deployment transaction.
Verify with Hardhat
Hardhat uses the @nomicfoundation/hardhat-verify plugin, which is included in the Hardhat toolboxes. In Hardhat 3, configure the key under verify:
// hardhat.config.ts (Hardhat 3)
import { configVariable } from "hardhat/config";
export default {
// ...solidity, networks, plugins
verify: {
etherscan: {
apiKey: configVariable("ETHERSCAN_API_KEY"),
},
},
};
In Hardhat 2 projects, it's a top-level etherscan: { apiKey: process.env.ETHERSCAN_API_KEY } instead. Then pass the address and the constructor arguments, in order:
npx hardhat verify --network sepolia 0xYourContractAddress "My Token" "MTK" 1000000000000000000000000
For complex arguments (structs, arrays, big numbers), put them in a module that exports an array and pass the file with the plugin's constructor-args file option instead of typing them on the command line.
Why "bytecode does not match" happens
Etherscan compiles your source and compares it with the creation bytecode from the deployment transaction, which is the contract's init code with the ABI-encoded constructor arguments appended. Anything that changes either part causes a mismatch.
Compiler version. It must be the exact version, e.g. 0.8.28, not "0.8.x". A pragma ^0.8.20 lets your tool pick any later version, so check which one actually ran in your build artifacts.
Optimizer and runs. Optimizer on or off, and the runs value, both change the bytecode. runs = 200 and runs = 1000 produce different code.
viaIR. Compiling through the Yul IR pipeline produces completely different bytecode. If you set via_ir = true in Foundry or viaIR: true in Hardhat, the verification must say so too.
EVM version. The target EVM version changes which opcodes the compiler uses (for example PUSH0 from Shanghai onwards), and the default target changes between compiler releases. If you set it explicitly, or deployed with a different compiler default, match it.
Constructor arguments. They must be ABI-encoded exactly as deployed. A different value, the wrong order, or a large number that lost precision as a JavaScript number all break the match.
Linked libraries. If the contract calls public or external library functions, the deployed bytecode contains the library's address. Pass those addresses (Foundry's --libraries path:Name:0xAddr, or the Hardhat plugin's libraries option).
Metadata hash. The end of the bytecode contains a hash of the compiler's metadata, which includes every source file's content and path. Changing a comment, a file path, a remapping or an import after deploying changes that hash. This is why flattening a contract into one file often fails: the paths no longer match.
Proxies. The proxy's own bytecode is not your logic. Verify the implementation contract first. Then open the proxy's page, choose More → Is this a proxy?, and click Verify. Etherscan detects the implementation from the standard storage slot and adds Read as Proxy / Write as Proxy tabs. Standard proxy contracts such as OpenZeppelin's ERC1967Proxy are often already matched automatically from identical bytecode.
Finding which setting is wrong
Compare the deployed code with a local build:
cast code 0xYourContractAddress --rpc-url $SEPOLIA_RPC_URL > onchain.txt
forge inspect MyToken deployedBytecode > local.txt
If the two differ only in the last few dozen bytes, the metadata hash differs: the sources or paths changed, but the settings are right. If they differ throughout, the compiler version, optimizer, viaIR or EVM version is wrong. Immutable variables are filled in at deploy time, so expect small differences where they sit.
The most reliable fix is to submit the Standard JSON Input, the exact JSON that was fed to solc. Foundry prints it with forge verify-contract --show-standard-json-input, and Hardhat stores it in the input field of the file in artifacts/build-info/. Upload it on Etherscan's "Verify & Publish" page with the "Solidity (Standard-Json-Input)" option.
Troubleshooting table
| Symptom | Likely cause | Fix |
|---|---|---|
| Bytecode differs everywhere | Wrong compiler version, optimizer runs or viaIR | Copy settings from the build artifacts |
| Only the tail differs | Source, comments, paths or remappings changed | Verify from the exact commit, or use Standard JSON Input |
| Match fails only on creation code | Wrong constructor arguments | Re-encode with cast abi-encode or --guess-constructor-args |
| Settings look right, code still differs | EVM version differs | Set the same evm_version / evmVersion |
| Missing library address errors | Linked external library | Pass the library addresses |
| Proxy shows only proxy code | Implementation not linked | Verify implementation, then "Is this a proxy?" |
| "Unable to locate contract code" | Too soon after deployment, or wrong chain | Wait a few blocks, check the chainid |
| API key or chain errors | V1 endpoint or unsupported chain for your plan | Update tools to the V2 API, check plan coverage |
Summary
Verify at deploy time whenever you can (forge create --verify, forge script --verify, or the Hardhat plugin right after deployment), because that's when the settings and arguments are guaranteed to match. When a later verification fails, treat it as a settings problem: compiler version, optimizer, viaIR, EVM version, constructor arguments, libraries and source paths. Submitting the Standard JSON Input from your build artifacts removes most of the guesswork.
Get the weekly commit
New blockchain deep dives every week.