Moving from npx hardhat run scripts/deploy.js
to actual mainnet deployments is where most teams lose their minds. I've seen $50M protocols fail because someone deployed the wrong contract version, and DevOps engineers wake up to Slack notifications about failed upgrades at 3am. This isn't a tutorial - it's a survival guide.
The Pre-Flight Checklist That Saves Careers
Version pinning isn't optional anymore. Pin EVERYTHING. I mean everything. Even the linter. One wrong dependency update has bricked more deployments than you'd believe:
// hardhat.config.ts - Lock down the world
module.exports = {
solidity: {
version: "0.8.19", // Specific version, not ^0.8.0
settings: {
optimizer: {
enabled: true,
runs: 200 // Not 1000, not 100 - exactly what you tested with
},
viaIR: true // For complex contracts hitting stack limits
}
}
};
Environment isolation saves companies. I watched a team accidentally deploy their dev contracts to mainnet because someone had MAINNET_RPC
in their .env
file. Use Hardhat's config variables for anything that matters:
## .env.example - What everyone should copy
SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_INFURA_KEY
DEPLOYER_PRIVATE_KEY=your_testnet_private_key_here
ETHERSCAN_API_KEY=your_etherscan_api_key
## What production actually needs
MAINNET_RPC_URL=https://mainnet.infura.io/v3/PRODUCTION_KEY
LEDGER_ACCOUNT=0x... # Hardware wallet address
MAINNET_ETHERSCAN_KEY=production_etherscan_key
Gas Estimation is Lying to You
Gas estimation in Hardhat assumes perfect network conditions that don't exist. Real networks have MEV bots, congestion, and gas price volatility that'll make your deployment fail halfway through.
The 1.5x rule that prevents disasters: Always multiply estimated gas by 1.5x for mainnet. Yes, you'll overpay sometimes. No, it's not worth the risk of a failed deployment eating $500 in gas fees:
// Production-ready gas configuration
const gasPrice = await ethers.provider.getGasPrice();
const deploymentTx = await factory.deploy(initArgs, {
gasLimit: estimatedGas.mul(150).div(100), // 1.5x estimated
gasPrice: gasPrice.mul(110).div(100), // 10% above current
maxFeePerGas: gasPrice.mul(150).div(100), // EIP-1559 buffer
});
Gas limit gotchas that bite everyone:
- Contract creation uses different gas calculations than calls
- Proxy deployments need gas for both proxy AND implementation
- OpenZeppelin upgrades add 20-30% overhead you don't see in tests
Network Configuration That Won't Fail You
Multi-network deployments are where teams realize their localhost setup means nothing. RPC providers have different behaviors, rate limits, and failure modes:
// networks.ts - Real network config that works
networks: {
mainnet: {
url: process.env.MAINNET_RPC_URL,
accounts: process.env.LEDGER_ACCOUNT ? [] : [process.env.DEPLOYER_KEY],
gasPrice: "auto",
timeout: 60000, // 1 minute timeout for congested periods
confirmations: 2, // Wait for 2 confirmations minimum
},
polygon: {
url: process.env.POLYGON_RPC_URL,
accounts: [process.env.DEPLOYER_KEY],
gasPrice: 35000000000, // 35 gwei - Polygon gas quirks
confirmations: 5, // Polygon needs more confirmations
},
arbitrum: {
url: "https://arb1.arbitrum.io/rpc",
accounts: [process.env.DEPLOYER_KEY],
gasPrice: "auto",
verify: {
etherscan: {
apiKey: process.env.ARBISCAN_API_KEY,
apiUrl: "https://api.arbiscan.io/"
}
}
}
}
RPC provider reality check:
- Infura rate limits at 100k requests/day on free tier
- Alchemy has better WebSocket support but different error handling
- QuickNode costs more but doesn't randomly throttle during high usage
Contract Verification Hell (And How to Escape It)
Contract verification fails more often than deployments. Etherscan verification breaks if your compiler settings don't exactly match what Etherscan expects:
// hardhat.config.ts - Verification that actually works
etherscan: {
apiKey: {
mainnet: process.env.ETHERSCAN_API_KEY,
polygon: process.env.POLYGONSCAN_API_KEY,
arbitrumOne: process.env.ARBISCAN_API_KEY,
},
customChains: [
{
network: "arbitrumOne",
chainId: 42161,
urls: {
apiURL: "https://api.arbiscan.io/api",
browserURL: "https://arbiscan.io/"
}
}
]
},
sourcify: {
enabled: true // Sourcify as backup verification
}
Verification debugging that saves hours:
- Constructor arguments must be ABI-encoded exactly
- Library linking breaks verification if addresses don't match
- Proxy contracts need separate verification for implementation and proxy
The Deployment Scripts That Don't Break
Most deployment scripts are garbage - they work once in perfect conditions then fail mysteriously. Here's what actually works in production:
// deploy/001_deploy_protocol.ts - Battle-tested deployment
import { HardhatRuntimeEnvironment } from "hardhat/types";
import { DeployFunction } from "hardhat-deploy/types";
const deployProtocol: DeployFunction = async (hre: HardhatRuntimeEnvironment) => {
const { deployments, getNamedAccounts, network } = hre;
const { deploy, execute } = deployments;
const { deployer } = await getNamedAccounts();
// Pre-flight checks that prevent disasters
const balance = await hre.ethers.provider.getBalance(deployer);
if (balance.lt(hre.ethers.utils.parseEther("0.1"))) {
throw new Error(`Deployer needs more ETH. Current: ${hre.ethers.utils.formatEther(balance)}`);
}
console.log(`Deploying to ${network.name} with ${deployer}`);
console.log(`Balance: ${hre.ethers.utils.formatEther(balance)} ETH`);
// Deploy with proper error handling
const factory = await deploy("TokenFactory", {
from: deployer,
args: [deployer], // Constructor args
log: true,
waitConfirmations: network.name === "mainnet" ? 2 : 1,
});
// Verify immediately - don't wait
if (network.name !== "hardhat" && network.name !== "localhost") {
try {
await hre.run("verify:verify", {
address: factory.address,
constructorArguments: [deployer],
});
} catch (error) {
console.log("Verification failed:", error.message);
}
}
console.log(`TokenFactory deployed to: ${factory.address}`);
// Save deployment info for later scripts
const deploymentInfo = {
network: network.name,
factory: factory.address,
deployer,
timestamp: new Date().toISOString(),
};
require("fs").writeFileSync(
`deployments-${network.name}.json`,
JSON.stringify(deploymentInfo, null, 2)
);
};
export default deployProtocol;
Hardware Wallet Integration (Because You're Not Storing Private Keys in .env Files, Right?)
Hardhat Ignition with Ledger is the only way to deploy serious money contracts. Anyone putting mainnet private keys in environment variables should be fired:
## Deploy with hardware wallet - the only sane approach
npx hardhat ignition deploy ignition/modules/Protocol.ts \
--network mainnet \
--strategy create2 \
--deployment-id production-v1.0.0 \
--ledger
## Verify the deployment worked correctly
npx hardhat ignition verify production-v1.0.0 --network mainnet
Hardware wallet gotchas that waste time:
- Ledger device must be unlocked and Ethereum app open
- Large deployments timeout - deploy in smaller modules
- Contract interaction requires confirming each transaction on device