OpenZeppelin Solidity Best Practices
Apply these rules whenever writing, reviewing, or auditing Solidity smart contracts that use OpenZeppelin v5.x.
Library & Imports
- Use OpenZeppelin Contracts v5.x. Import from
@openzeppelin/contracts(or@openzeppelin/contracts-upgradeablefor upgradeable variants). - Always use named imports:
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol" - Import only what is used. Prefer interface imports (
IERC20,IERC721) when only type information is needed. - Pin the exact OZ version in
package.jsonorfoundry.toml; never use"latest". - OZ v5 removed
SafeMath; rely on Solidity 0.8.x built-in overflow protection instead. - OZ v5
Ownablerequires passinginitialOwnerto the constructor; passingaddress(0)reverts. - ERC-721 v5 replaced
_beforeTokenTransfer/_afterTokenTransferwith a single_update(to, tokenId, auth)hook.
Access Control
- Use
Ownable2Step(notOwnable) for any contract managing funds or critical parameters. - Use
AccessControlwhen multiple distinct roles are needed (MINTER_ROLE,PAUSER_ROLE,UPGRADER_ROLE). - Define role constants as:
bytes32 public constant ROLE_NAME = keccak256("ROLE_NAME") - Always grant
DEFAULT_ADMIN_ROLEto a multisig, never to an EOA in production. - Wrap governance-critical parameter changes with
TimelockController; minimum delay: 24 h for params, 48 h for upgrades. - Never mix
onlyOwnerandAccessControlin the same contract; choose one model.
Security Patterns
- Inherit
ReentrancyGuardand applynonReentrantto every function that transfers ETH or calls external contracts. - Always follow Checks-Effects-Interactions (CEI): validate → update state → external call.
- Inherit
Pausable; restrictpause()to aPAUSER_ROLE,unpause()toDEFAULT_ADMIN_ROLE. - Use
unchecked {}only for loop counters where overflow is mathematically impossible. - Cast down with
SafeCast.toUint128()instead of silent truncation. - Use
ECDSA.recover(hash, signature)and always hash withMessageHashUtils.toEthSignedMessageHash(hash). - For structured data, use the
EIP712base contract; never build domain separators manually. - Protect against signature replay with a per-account nonce mapping.
- Never use
tx.originfor authentication; always usemsg.sender. - Never use
block.timestamporblockhashas a source of randomness; use Chainlink VRF. - Validate that address parameters are not
address(0)before use.
ERC-20 Tokens
- Inherit from
ERC20orERC20Burnabledepending on whether supply needs to decrease. - Inherit
ERC20Permitfor gasless approvals via off-chain signatures (EIP-2612). - Use
ERC20Voteswhen the token needs governance voting power tracking. - Use
ERC20Cappedto enforce a hard maximum supply at contract level. - For fee-on-transfer: override
_transfer, store fee in basis points (feeBps / 10000), whitelist exempt addresses in a mapping. _mintonly in constructor or access-controlled minting functions, never in unrestricted public functions.
ERC-721 / ERC-1155 NFTs
- Use
_safeMint(not_mint) to prevent tokens being locked in contracts that don't implementIERC721Receiver. - Use
ERC721URIStoragewhen each token needs an individual URI; useERC721Enumerableonly if on-chain enumeration is strictly required. - Use a monotonically increasing counter (
uint256 _nextTokenId) for token IDs; never reuse them. - Inherit
ERC2981for standard royalty info; use_setDefaultRoyaltyin constructor. - For ERC-1155: use
ERC1155Supplyto track supply per token ID; batch mint with_mintBatchfor airdrops. - Gate public minting behind a price check and supply cap; use
MerkleProof.verifyfor allowlist minting.
Gas Optimization
- Pack related variables into the same 32-byte slot:
uint128 + uint128in one slot vs two separate slots. - Cache storage reads in local memory variables when the same slot is read more than once in a function.
- Use
calldatainstead ofmemoryfor external function parameters that are not modified. - Mark functions as
externalinstead ofpublicwhen they are never called internally. - Use
++iinstead ofi++in loops; useunchecked { ++i; }for counters that cannot overflow. - Cache array length before loops:
uint256 len = arr.length. - Use custom errors (
error Unauthorized(address caller)) instead ofrequirestrings — 50–70% gas saving on revert paths. - Use events for off-chain-only data; do not store history in mappings.
Storage, Events & Errors
- Use
immutablefor values set in the constructor that never change (zeroSLOADcost). - Use
constantfor compile-time known values (inlined, zero gas). - Index event parameters that will be used as filter criteria; limit to 3
indexedper event (EVM constraint). - Use descriptive past-tense event names:
TokenMinted,RewardClaimed,RoleRevoked. - Add
@notice,@param,@return,@dev, and@custom:security-contactNatSpec to all public/external functions. - Never store large strings or bytes on-chain; store only a content-addressed hash (IPFS CID or
keccak256).
Upgradeable Proxies
- Inherit
UUPSUpgradeableand override_authorizeUpgradewithonlyOwneror a governance check. - Mark the initializer with the
initializermodifier fromInitializable; call all__Parent_init()functions in order. - Add
_disableInitializers()call in the logic contract constructor to prevent direct initialization. - Never change the order or type of existing storage variables between upgrades; only append at the end.
- Use
uint256[50] private __gapat the end of each upgradeable base contract. - Run
storageLayout diffwith the OZ Upgrades Hardhat plugin before every upgrade. - Transfer
ProxyAdminownership to a multisig immediately after deployment.
DeFi Patterns
- Inherit
ERC4626for yield-bearing vaults; override_decimalsOffset()to mitigate inflation attacks. - For staking rewards, use the Synthetix-style per-token accumulator; call
updateReward(account)before any stake/unstake. - Validate Chainlink feed answers:
require(answer > 0)and checkupdatedAtis within a staleness threshold. - Use
latestRoundData()(notlatestAnswer()) to access timestamp andansweredInRound. - Never use a single oracle as the sole price source for liquidations.
Testing
- Foundry: inherit
Testfromforge-std/Test.sol; name teststest_<Function>_<scenario>. - Use
vm.prank(address)for single-call impersonation;vm.startPrank/stopPrankfor multi-call sequences. - Use
vm.expectRevert(CustomError.selector)before calls that should revert. - For fuzz tests: use
vm.assume(condition)to filter inputs,bound(x, min, max)for realistic ranges. - Pin fork block number with
vm.rollFork(blockNumber)for deterministic CI tests. - Hardhat: use
loadFixtureto reset state between tests;expect(tx).to.emit(contract, "Event").withArgs(...)for events. - Write invariant tests (
invariant_prefix) asserting system-wide properties (e.g.,totalSupply == sum of balances). - Enforce minimum 90% line coverage with
forge coverage --report lcov.
Deployment
- Simulate on a mainnet fork with the exact same script before broadcasting.
- Save all deployment addresses, constructor args, and block numbers in
deployments/<network>.json. - Load private keys exclusively from environment variables; never commit them.
- Transfer ownership to a Gnosis Safe multisig as the final step of every mainnet deployment.
- Verify source code on Etherscan immediately after deployment with the exact compiler version and optimizer settings.
- Run
slitherandmythrilstatic analysis on final bytecode before deployment.