582 lines
13 KiB
Markdown
582 lines
13 KiB
Markdown
# Solidity Patterns and Idioms
|
|
|
|
Common patterns for modern Solidity 0.8.30 smart contract development.
|
|
|
|
## Access Control Patterns
|
|
|
|
### Ownable Pattern
|
|
|
|
Single owner - simplest but centralized:
|
|
|
|
```solidity
|
|
contract Ownable {
|
|
address private _owner;
|
|
|
|
error Unauthorized();
|
|
|
|
modifier onlyOwner() {
|
|
if (msg.sender != _owner) revert Unauthorized();
|
|
_;
|
|
}
|
|
|
|
constructor() {
|
|
_owner = msg.sender;
|
|
}
|
|
|
|
function owner() public view returns (address) {
|
|
return _owner;
|
|
}
|
|
|
|
function transferOwnership(address newOwner) public onlyOwner {
|
|
require(newOwner != address(0), "Invalid owner");
|
|
_owner = newOwner;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Use:** Simple contracts, single admin
|
|
**Pitfall:** Single point of failure
|
|
|
|
### Role-Based Access Control
|
|
|
|
Fine-grained permissions:
|
|
|
|
```solidity
|
|
contract AccessControl {
|
|
mapping(bytes32 => mapping(address => bool)) private _roles;
|
|
|
|
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
|
|
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
|
|
|
|
error AccessDenied(address account, bytes32 role);
|
|
|
|
modifier onlyRole(bytes32 role) {
|
|
if (!_roles[role][msg.sender])
|
|
revert AccessDenied(msg.sender, role);
|
|
_;
|
|
}
|
|
|
|
function grantRole(bytes32 role, address account) public onlyRole(ADMIN_ROLE) {
|
|
_roles[role][account] = true;
|
|
}
|
|
|
|
function hasRole(bytes32 role, address account) public view returns (bool) {
|
|
return _roles[role][account];
|
|
}
|
|
}
|
|
```
|
|
|
|
**Use:** DeFi protocols, complex permissions
|
|
**Recommendation:** Use OpenZeppelin's AccessControl
|
|
|
|
### Multi-Signature Pattern
|
|
|
|
Require multiple approvals for critical operations:
|
|
|
|
```solidity
|
|
contract MultiSig {
|
|
address[] public owners;
|
|
uint256 public requiredSignatures;
|
|
|
|
mapping(bytes32 => mapping(address => bool)) public confirmations;
|
|
mapping(bytes32 => bool) public executed;
|
|
|
|
function submitTransaction(
|
|
address target,
|
|
uint256 value,
|
|
bytes calldata data
|
|
) external returns (bytes32) {
|
|
bytes32 txHash = keccak256(abi.encode(target, value, data, block.timestamp));
|
|
confirmations[txHash][msg.sender] = true;
|
|
return txHash;
|
|
}
|
|
|
|
function executeTransaction(
|
|
address target,
|
|
uint256 value,
|
|
bytes calldata data,
|
|
bytes32 txHash
|
|
) external {
|
|
require(!executed[txHash], "Already executed");
|
|
|
|
uint256 count = 0;
|
|
for (uint256 i = 0; i < owners.length; i++) {
|
|
if (confirmations[txHash][owners[i]]) count++;
|
|
}
|
|
require(count >= requiredSignatures, "Not enough signatures");
|
|
|
|
executed[txHash] = true;
|
|
(bool success,) = target.call{value: value}(data);
|
|
require(success, "Execution failed");
|
|
}
|
|
}
|
|
```
|
|
|
|
**Use:** Treasury, critical upgrades, governance
|
|
|
|
## Reentrancy Protection
|
|
|
|
### Checks-Effects-Interactions (CEI)
|
|
|
|
Most important pattern - prevent state inconsistencies:
|
|
|
|
```solidity
|
|
// BAD: State update AFTER external call
|
|
function withdrawBad() external {
|
|
uint256 amount = balances[msg.sender];
|
|
(bool success,) = msg.sender.call{value: amount}("");
|
|
require(success);
|
|
balances[msg.sender] = 0; // Vulnerable!
|
|
}
|
|
|
|
// GOOD: CEI pattern
|
|
function withdrawGood() external {
|
|
uint256 amount = balances[msg.sender];
|
|
balances[msg.sender] = 0; // Effects first
|
|
(bool success,) = msg.sender.call{value: amount}("");
|
|
require(success); // Interactions last
|
|
}
|
|
```
|
|
|
|
### Mutex Lock (ReentrancyGuard)
|
|
|
|
Traditional approach using storage:
|
|
|
|
```solidity
|
|
contract ReentrancyGuard {
|
|
uint256 private constant NOT_ENTERED = 1;
|
|
uint256 private constant ENTERED = 2;
|
|
uint256 private _status = NOT_ENTERED;
|
|
|
|
modifier nonReentrant() {
|
|
require(_status != ENTERED, "ReentrancyGuard: reentrant call");
|
|
_status = ENTERED;
|
|
_;
|
|
_status = NOT_ENTERED;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Cost:** ~5,000 gas (2 SSTOREs)
|
|
|
|
### Transient Storage Lock (0.8.28+)
|
|
|
|
Gas-efficient using EIP-1153:
|
|
|
|
```solidity
|
|
contract TransientReentrancyGuard {
|
|
modifier nonReentrant() {
|
|
assembly {
|
|
if tload(0) { revert(0, 0) }
|
|
tstore(0, 1)
|
|
}
|
|
_;
|
|
assembly {
|
|
tstore(0, 0)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Cost:** ~200 gas (TSTORE/TLOAD) - 25x cheaper
|
|
|
|
## Factory Patterns
|
|
|
|
### Basic Factory (CREATE)
|
|
|
|
```solidity
|
|
contract TokenFactory {
|
|
address[] public deployedTokens;
|
|
|
|
event TokenCreated(address indexed token, address indexed creator);
|
|
|
|
function createToken(string memory name, string memory symbol)
|
|
external
|
|
returns (address)
|
|
{
|
|
Token token = new Token(name, symbol, msg.sender);
|
|
deployedTokens.push(address(token));
|
|
emit TokenCreated(address(token), msg.sender);
|
|
return address(token);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Use:** Simple deployments where address doesn't matter
|
|
|
|
### Deterministic Factory (CREATE2)
|
|
|
|
Deploy to predictable addresses across chains:
|
|
|
|
```solidity
|
|
contract DeterministicFactory {
|
|
event Deployed(address indexed addr, bytes32 salt);
|
|
|
|
function deploy(bytes32 salt, bytes memory bytecode)
|
|
external
|
|
returns (address addr)
|
|
{
|
|
assembly {
|
|
addr := create2(0, add(bytecode, 0x20), mload(bytecode), salt)
|
|
if iszero(extcodesize(addr)) { revert(0, 0) }
|
|
}
|
|
emit Deployed(addr, salt);
|
|
}
|
|
|
|
function computeAddress(bytes32 salt, bytes32 bytecodeHash)
|
|
external
|
|
view
|
|
returns (address)
|
|
{
|
|
return address(uint160(uint256(keccak256(abi.encodePacked(
|
|
bytes1(0xff),
|
|
address(this),
|
|
salt,
|
|
bytecodeHash
|
|
)))));
|
|
}
|
|
}
|
|
```
|
|
|
|
**Use:** Counterfactual deployments, cross-chain consistency
|
|
|
|
### Minimal Proxy (ERC-1167 Clones)
|
|
|
|
Deploy cheap proxies (~45 bytes):
|
|
|
|
```solidity
|
|
contract CloneFactory {
|
|
function clone(address implementation) internal returns (address instance) {
|
|
assembly {
|
|
mstore(0x00, or(
|
|
shr(0xe8, shl(0x60, implementation)),
|
|
0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000
|
|
))
|
|
mstore(0x20, or(shl(0x78, implementation), 0x5af43d82803e903d91602b57fd5bf3))
|
|
instance := create(0, 0x09, 0x37)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Cost:** ~10K gas vs ~170K for full contract
|
|
**Use:** Token factories, vault templates
|
|
|
|
## Payment Patterns
|
|
|
|
### Pull Over Push
|
|
|
|
**Push (Anti-pattern):**
|
|
```solidity
|
|
// BAD: Can fail if recipient reverts
|
|
function sendRewards() external {
|
|
for (uint256 i = 0; i < recipients.length; i++) {
|
|
payable(recipients[i]).transfer(amounts[i]);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Pull (Recommended):**
|
|
```solidity
|
|
contract PullPayments {
|
|
mapping(address => uint256) public pendingWithdrawals;
|
|
|
|
function claimReward() external {
|
|
uint256 amount = pendingWithdrawals[msg.sender];
|
|
require(amount > 0, "Nothing to claim");
|
|
|
|
pendingWithdrawals[msg.sender] = 0;
|
|
(bool success,) = msg.sender.call{value: amount}("");
|
|
require(success, "Transfer failed");
|
|
}
|
|
}
|
|
```
|
|
|
|
**Why pull is safer:**
|
|
- User controls when to withdraw
|
|
- Failing transfers don't block protocol
|
|
- Prevents DoS via malicious fallbacks
|
|
|
|
## Emergency Controls
|
|
|
|
### Pausable Pattern
|
|
|
|
```solidity
|
|
contract Pausable {
|
|
bool public paused;
|
|
address public owner;
|
|
|
|
error ContractPaused();
|
|
|
|
modifier whenNotPaused() {
|
|
if (paused) revert ContractPaused();
|
|
_;
|
|
}
|
|
|
|
function pause() external onlyOwner {
|
|
paused = true;
|
|
}
|
|
|
|
function unpause() external onlyOwner {
|
|
paused = false;
|
|
}
|
|
|
|
function transfer(address to, uint256 amount) external whenNotPaused {
|
|
// Transfer logic
|
|
}
|
|
}
|
|
```
|
|
|
|
**Use:** Security response, gradual rollouts
|
|
|
|
## Proxy Patterns
|
|
|
|
### UUPS (Recommended)
|
|
|
|
Implementation controls upgrades:
|
|
|
|
```solidity
|
|
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
|
|
|
|
contract MyContractV1 is UUPSUpgradeable {
|
|
uint256 public value;
|
|
|
|
function initialize(uint256 _value) public initializer {
|
|
value = _value;
|
|
}
|
|
|
|
function _authorizeUpgrade(address newImplementation)
|
|
internal
|
|
override
|
|
onlyOwner
|
|
{}
|
|
}
|
|
```
|
|
|
|
### Transparent Proxy
|
|
|
|
Admin calls go to proxy, user calls go to implementation:
|
|
|
|
```solidity
|
|
// Use OpenZeppelin's TransparentUpgradeableProxy
|
|
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
|
|
```
|
|
|
|
**Trade-offs:**
|
|
- UUPS: Smaller proxy, upgrade logic in implementation
|
|
- Transparent: Larger proxy, clearer admin separation
|
|
|
|
## State Machine Pattern
|
|
|
|
```solidity
|
|
contract Auction {
|
|
enum Phase { Bidding, Reveal, Finished }
|
|
|
|
Phase public currentPhase;
|
|
uint256 public phaseDeadline;
|
|
|
|
error WrongPhase(Phase expected, Phase actual);
|
|
|
|
modifier atPhase(Phase expected) {
|
|
_checkPhaseTransition();
|
|
if (currentPhase != expected)
|
|
revert WrongPhase(expected, currentPhase);
|
|
_;
|
|
}
|
|
|
|
function _checkPhaseTransition() internal {
|
|
if (block.timestamp >= phaseDeadline) {
|
|
currentPhase = Phase(uint256(currentPhase) + 1);
|
|
phaseDeadline = block.timestamp + 1 days;
|
|
}
|
|
}
|
|
|
|
function placeBid(bytes32 hashedBid) external atPhase(Phase.Bidding) {
|
|
// Bidding logic
|
|
}
|
|
|
|
function revealBid(uint256 value, bytes32 secret) external atPhase(Phase.Reveal) {
|
|
// Reveal logic
|
|
}
|
|
}
|
|
```
|
|
|
|
## Commit-Reveal Scheme
|
|
|
|
Prevent front-running:
|
|
|
|
```solidity
|
|
contract CommitReveal {
|
|
mapping(address => bytes32) public commits;
|
|
mapping(address => uint256) public commitTimes;
|
|
|
|
uint256 public constant REVEAL_DELAY = 1 hours;
|
|
|
|
function commit(bytes32 hash) external {
|
|
commits[msg.sender] = hash;
|
|
commitTimes[msg.sender] = block.timestamp;
|
|
}
|
|
|
|
function reveal(uint256 value, bytes32 salt) external {
|
|
require(block.timestamp >= commitTimes[msg.sender] + REVEAL_DELAY, "Too early");
|
|
require(commits[msg.sender] == keccak256(abi.encode(value, salt, msg.sender)), "Invalid reveal");
|
|
|
|
delete commits[msg.sender];
|
|
// Process revealed value
|
|
}
|
|
}
|
|
```
|
|
|
|
**Use:** Auctions, voting, sealed-bid protocols
|
|
|
|
## Timelock Pattern
|
|
|
|
```solidity
|
|
contract Timelock {
|
|
uint256 public constant DELAY = 2 days;
|
|
|
|
mapping(bytes32 => uint256) public queuedTransactions;
|
|
|
|
function queue(address target, bytes calldata data) external returns (bytes32) {
|
|
bytes32 txHash = keccak256(abi.encode(target, data, block.timestamp));
|
|
queuedTransactions[txHash] = block.timestamp + DELAY;
|
|
return txHash;
|
|
}
|
|
|
|
function execute(address target, bytes calldata data, bytes32 txHash) external {
|
|
uint256 executeTime = queuedTransactions[txHash];
|
|
require(executeTime != 0, "Not queued");
|
|
require(block.timestamp >= executeTime, "Too early");
|
|
|
|
delete queuedTransactions[txHash];
|
|
(bool success,) = target.call(data);
|
|
require(success, "Execution failed");
|
|
}
|
|
}
|
|
```
|
|
|
|
**Use:** Governance, critical upgrades, parameter changes
|
|
|
|
## Error Handling
|
|
|
|
### Custom Errors (Preferred)
|
|
|
|
```solidity
|
|
error InsufficientBalance(uint256 available, uint256 required);
|
|
error Unauthorized(address caller);
|
|
error ZeroAddress();
|
|
|
|
function withdraw(uint256 amount) external {
|
|
if (balances[msg.sender] < amount)
|
|
revert InsufficientBalance(balances[msg.sender], amount);
|
|
// ...
|
|
}
|
|
```
|
|
|
|
**Advantages:**
|
|
- ~90% gas savings vs require strings
|
|
- Structured error data for debugging
|
|
- Type-safe parameters
|
|
|
|
## Initialization Patterns
|
|
|
|
### Constructor (Non-upgradeable)
|
|
|
|
```solidity
|
|
contract Permanent {
|
|
address public immutable owner;
|
|
uint256 public immutable maxSupply;
|
|
|
|
constructor(address _owner, uint256 _maxSupply) {
|
|
owner = _owner;
|
|
maxSupply = _maxSupply;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Initializer (Upgradeable)
|
|
|
|
```solidity
|
|
contract Upgradeable {
|
|
bool private _initialized;
|
|
address public owner;
|
|
|
|
modifier initializer() {
|
|
require(!_initialized, "Already initialized");
|
|
_initialized = true;
|
|
_;
|
|
}
|
|
|
|
function initialize(address _owner) external initializer {
|
|
owner = _owner;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Critical:** Constructors don't run on proxies - use initializers
|
|
|
|
## Common Pitfalls
|
|
|
|
### tx.origin vs msg.sender
|
|
|
|
```solidity
|
|
// WRONG: Vulnerable to phishing
|
|
if (tx.origin == owner) { /* ... */ }
|
|
|
|
// CORRECT
|
|
if (msg.sender == owner) { /* ... */ }
|
|
```
|
|
|
|
### Storage Layout with Upgrades
|
|
|
|
```solidity
|
|
// V1
|
|
contract V1 {
|
|
uint256 public value; // slot 0
|
|
}
|
|
|
|
// V2 - CORRECT: Append only
|
|
contract V2 is V1 {
|
|
uint256 public newValue; // slot 1
|
|
}
|
|
|
|
// V2 - WRONG: Inserting breaks layout
|
|
contract V2Bad {
|
|
uint256 public newValue; // slot 0 - collision!
|
|
uint256 public value; // slot 1
|
|
}
|
|
```
|
|
|
|
### Unbounded Loops
|
|
|
|
```solidity
|
|
// DANGEROUS: Can run out of gas
|
|
function distributeAll() external {
|
|
for (uint256 i = 0; i < recipients.length; i++) {
|
|
payable(recipients[i]).transfer(amounts[i]);
|
|
}
|
|
}
|
|
|
|
// SAFE: Batch processing
|
|
function distributeBatch(uint256 start, uint256 end) external {
|
|
require(end <= recipients.length && end - start <= 100);
|
|
for (uint256 i = start; i < end; i++) {
|
|
payable(recipients[i]).transfer(amounts[i]);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Unchecked External Calls
|
|
|
|
```solidity
|
|
// WRONG: Return value ignored
|
|
token.transfer(user, amount);
|
|
|
|
// CORRECT: Check return
|
|
bool success = token.transfer(user, amount);
|
|
require(success, "Transfer failed");
|
|
|
|
// BEST: Use SafeERC20
|
|
IERC20(token).safeTransfer(user, amount);
|
|
```
|