Before Hardhat's console.log support, debugging Solidity was like trying to fix a car engine while blindfolded. You'd add require statements with different revert messages just to figure out where your function was failing. It was barbaric.
The Breakthrough: Actual Logging in Solidity
Console.log in Solidity works exactly like you'd expect from any modern programming language. Import the library, add your logs, run your tests, see the output. Revolutionary stuff for 2025.
import "hardhat/console.sol";
contract DebuggingExample {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) public {
console.log("=== Transfer Debug Info ===");
console.log("From:", msg.sender);
console.log("To:", to);
console.log("Amount requested:", amount);
console.log("Sender balance:", balances[msg.sender]);
console.log("Contract ETH balance:", address(this).balance);
require(balances[msg.sender] >= amount, "Insufficient balance");
console.log("✓ Balance check passed");
balances[msg.sender] -= amount;
console.log("Sender new balance:", balances[msg.sender]);
balances[to] += amount;
console.log("Recipient new balance:", balances[to]);
console.log("=== Transfer Complete ===");
}
}
Advanced Console.log Techniques
Conditional logging for complex debugging scenarios:
function complexCalculation(uint256 input) public returns (uint256) {
if (input > 1000000) {
console.log("🚨 Large input detected:", input);
console.log("Gas remaining:", gasleft());
}
uint256 result = (input * 150) / 100;
// Log intermediate steps for specific ranges
if (input >= 500 && input <= 1500) {
console.log("Mid-range calculation - input:", input, "result:", result);
}
return result;
}
Debugging storage layout issues:
contract StorageDebug {
uint256 private slot0;
address private slot1;
mapping(address => uint256) private slot2;
function debugStorage(address user) public view {
console.log("=== Storage Debug ===");
console.log("slot0 value:", slot0);
console.log("slot1 value:", slot1);
console.log("User balance in slot2:", slot2[user]);
// Debug packed storage
uint128 packed1 = 12345;
uint128 packed2 = 67890;
console.log("Packed values - first:", packed1, "second:", packed2);
}
}
Performance Impact Reality Check
Does console.log affect gas usage? In Hardhat Network, no. The console.log calls are stripped out when compiling for actual deployment. But there's a catch - they do slow down test execution slightly because they're processed during runtime. See the Hardhat debugging guide for performance considerations.
Best practices for production debugging:
- Leave debugging code in during development - comment out before mainnet deployment
- Use conditional compilation for extensive logging with Solidity preprocessor directives:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// Only import console in development
import "hardhat/console.sol";
contract MyContract {
function sensitiveOperation(uint256 amount) public {
// Wrap debug logs in compilation flags
console.log("Debug: processing amount", amount);
// Your actual logic here
require(amount > 0, "Amount must be positive");
}
}
The EDR Runtime Debugging Advantage
Hardhat's EDR (Ethereum Development Runtime) takes debugging beyond just console.log. When contracts fail, you get actual stack traces pointing to the exact Solidity line, not just EVM opcodes.
Before EDR (the dark ages):
Error: Transaction reverted without a reason string
at Contract.someFunction (Contract.sol)
at processTicksAndRejections (node:internal/process/task_queues:95:5)
After EDR (enlightenment):
Error: VM Exception while processing transaction: revert Insufficient balance
at MyContract.transfer (contracts/MyContract.sol:45:9)
at HardhatNode._mineBlockWithPendingTxs (hardhat/src/internal/hardhat-network/provider/node.ts:1773:23)
Contract call stack:
MyContract.transfer(to=0x1234..., amount=1000)
ERC20.transferFrom(from=0x5678..., to=0x1234..., amount=1000)
require(balanceOf[from] >= amount) <- FAILURE POINT
The stack trace includes your variable values and shows the exact require statement that failed. This alone saves hours of debugging compared to the cryptic EVM errors you get with other tools like Remix or older Hardhat versions.
Mainnet Forking: Debug Against Real State
The most powerful debugging technique: fork mainnet and debug against real deployed contracts with real state.
// hardhat.config.js - Fork mainnet for debugging
networks: {
hardhat: {
forking: {
url: "https://mainnet.infura.io/v3/YOUR_INFURA_KEY",
blockNumber: 18500000 // Pin to specific block for reproducible debugging
}
}
}
Debug real protocol interactions:
describe("Debug Uniswap interaction", function() {
it("should debug failed swap", async function() {
// Fork mainnet and impersonate a whale account
await network.provider.request({
method: "hardhat_impersonateAccount",
params: ["0x8ba1f109551bD432803012645Hac136c"] // Vitalik's address
});
const vitalik = await ethers.getSigner("0x8ba1f109551bD432803012645Hac136c");
// Debug the exact swap that's failing
const uniswapRouter = await ethers.getContractAt(
"IUniswapV2Router02",
"0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D"
);
// This will show console.logs from Uniswap's contracts if they have them
await uniswapRouter.connect(vitalik).swapExactETHForTokens(
0,
["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "0xdAC17F958D2ee523a2206206994597C13D831ec7"],
vitalik.address,
Math.floor(Date.now() / 1000) + 300,
{ value: ethers.utils.parseEther("1") }
);
});
});
State override debugging - inject console.log into existing contracts:
tracer: {
stateOverrides: {
"0xA0b86a33E6C6a4f6e7e8c3f0Ea8D0DAb7f4C1234": { // Target contract
bytecode: "MyDebuggingContract" // Your version with console.logs
}
}
}
This lets you add debugging to contracts you don't own - essential when your DeFi protocol interacts with Uniswap, Compound, Aave, etc.
Gas Profiling and Transaction Tracing
Beyond console.log: Use hardhat-tracer to see every internal call, event, and storage operation:
## See everything that happens in a transaction
npx hardhat test --vvvv
## Focus on specific opcodes (storage operations)
npx hardhat test --vvv --opcodes SSTORE,SLOAD
## Trace mainnet transactions
npx hardhat trace --hash 0x1234abcd --rpc "https://mainnet.infura.io/v3/YOUR_KEY_HERE"
Real output from hardhat-tracer shows you the exact gas costs and call hierarchy:
├─ MyContract.complexFunction() [Gas: 85,432]
│ ├─ ERC20.approve(spender=0x1234..., amount=1000) [Gas: 22,567]
│ │ └─ emit Approval(owner=0x5678..., spender=0x1234..., value=1000)
│ ├─ UniswapRouter.swapExactTokensForTokens(...) [Gas: 45,234]
│ │ ├─ UniswapPair.getReserves() [Gas: 2,345]
│ │ │ └─ return (112340000000000000000, 445670000000000000000, 1640995200)
│ │ └─ WETH.transfer(to=0x9abc..., amount=234500000000000000) [Gas: 23,456]
│ └─ console.log("Swap completed, received:", 234500000000000000)
This level of visibility into contract execution doesn't exist in any other Ethereum development environment. You can see exactly which function calls consume the most gas, what events are emitted, and where your transaction might be failing.
The Reality of Professional Smart Contract Debugging
Professional DeFi protocols like Uniswap, Aave, and Compound all use Hardhat's debugging features extensively. When you're dealing with millions of dollars in TVL, "transaction reverted" isn't acceptable - you need to know exactly what went wrong.
The combination of console.log, stack traces, mainnet forking, and transaction tracing makes Hardhat the only development environment where you can actually debug complex smart contract interactions effectively. Everything else is guesswork.