
Smart Contract Development: Best Practices for Security and Efficiency
The $3 Billion Question
Smart contract bugs have cost the crypto industry over 60M), Parity multi-sig freeze (600M), Ronin Bridge ($625M) — the list goes on. Every single one of these was preventable with better development practices.
Here's what makes smart contract development uniquely challenging: your code is public, immutable, and manages real value from day one. There's no "move fast and break things." There's no rolling back a bad deployment. Once your contract is on-chain, any bug is a potential exploit waiting to be discovered.
I've been writing smart contracts for four years now, and I've learned these lessons the hard way — through code reviews that found critical bugs, through near-misses that could have been disasters, and through studying every major exploit in detail. Let me share what I've learned about writing secure and efficient smart contracts.
The Fundamental Mindset Shift
Before we dive into specific patterns and practices, you need to internalize a fundamental mindset shift. Smart contracts are not like traditional software development.
In web development, if you find a bug, you push a patch. Users might experience some downtime or data inconsistency, but you fix it and move on.
In smart contract development, bugs can be irreversible and catastrophic. An attacker can drain your entire contract in minutes. There's no customer support to call, no charge-back mechanism, no "undo" button. The code is the law, and if the code has a vulnerability, that vulnerability becomes a feature that anyone can exploit.
This means:
- Assume adversarial conditions. Every function will be called with malicious inputs. Every sequence of operations will be attempted. Every assumption will be tested.
- Minimize complexity. Each line of code is a potential vulnerability. Simpler code is easier to audit and less likely to contain subtle bugs.
- Defense in depth. Use multiple layers of security. Don't rely on a single check or pattern to keep funds safe.
- Fail safely. When something goes wrong, the contract should fail in a way that doesn't lose funds or break invariants.
Security Patterns Every Developer Must Know
1. Checks-Effects-Interactions (CEI)
This is the most important pattern in smart contract development. It prevents reentrancy attacks — one of the most common and dangerous vulnerabilities.
The pattern is simple: order your code in three phases:
Checks: Validate all conditions and requirements Effects: Update your contract's state Interactions: Make external calls to other contracts
Here's a bad example (vulnerable to reentrancy):
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
// DANGER: External call before state update
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount; // Too late!
}
An attacker can exploit this by having their contract's receive function call withdraw again before balances is updated. They can drain the entire contract.
Here's the correct implementation:
function withdraw(uint256 amount) public {
// Checks
require(balances[msg.sender] >= amount, "Insufficient balance");
// Effects (update state BEFORE external calls)
balances[msg.sender] -= amount;
// Interactions (external call last)
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
Even better, use OpenZeppelin's ReentrancyGuard for an additional layer of protection:
function withdraw(uint256 amount) public nonReentrant {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
2. Access Control
Never write your own access control from scratch. Use established patterns like OpenZeppelin's Ownable or AccessControl.
Bad approach:
address public admin;
function criticalFunction() public {
require(msg.sender == admin, "Not admin");
// ... critical logic
}
This works, but it's fragile. What if you need multiple admins? What if you need role-based permissions? What if you want to transfer ownership?
Better approach using OpenZeppelin:
import "@openzeppelin/contracts/access/AccessControl.sol";
contract MyContract is AccessControl {
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
constructor() {
_setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
_setupRole(ADMIN_ROLE, msg.sender);
}
function criticalFunction() public onlyRole(ADMIN_ROLE) {
// ... critical logic
}
function operatorFunction() public onlyRole(OPERATOR_ROLE) {
// ... operator logic
}
}
This gives you flexible, audited, battle-tested access control with minimal code.
3. Pull Over Push for Payments
Never push payments to users. Let them pull funds instead. This prevents a whole class of bugs where a failed transfer can brick your contract.
Bad pattern (push):
function distribute() public {
for (uint i = 0; i < beneficiaries.length; i++) {
// If one transfer fails, the entire function reverts
beneficiaries[i].transfer(amounts[i]);
}
}
Good pattern (pull):
mapping(address => uint256) public pendingWithdrawals;
function recordPayment(address beneficiary, uint256 amount) internal {
pendingWithdrawals[beneficiary] += amount;
}
function withdraw() public {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "No funds available");
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
This way, one user's failure to receive payment doesn't affect others.
4. Integer Overflow Protection
Pre-Solidity 0.8, integer overflow was a major vulnerability. Now, arithmetic operations revert on overflow by default, which is great. But you still need to be careful:
// This is safe in Solidity 0.8+, will revert on overflow
uint256 total = userBalance + amount;
// But sometimes you want to allow overflow (rare!)
// Use unchecked for gas savings when you're certain it's safe
unchecked {
counter++; // Won't revert on overflow
}
Use unchecked judiciously and only when you're absolutely certain overflow is impossible or acceptable.
5. Oracle and External Data Handling
When consuming external data (price feeds, random numbers, etc.), never trust it blindly:
function buyWithOracle(uint256 tokenId) public payable {
uint256 price = priceOracle.getPrice(tokenId);
// ALWAYS validate oracle data
require(price > 0, "Invalid price");
require(price < MAX_REASONABLE_PRICE, "Price too high");
require(block.timestamp - priceOracle.lastUpdate() < 1 hours, "Stale price");
require(msg.value >= price, "Insufficient payment");
// ... proceed with purchase
}
Oracles can be manipulated, outdated, or compromised. Always validate their data.
Gas Optimization Strategies
Security is paramount, but gas efficiency matters too. Here are the optimizations I use most frequently:
1. Storage vs Memory vs Calldata
Storage is expensive. Memory is cheaper. Calldata is cheapest. Choose wisely:
// BAD: Unnecessary storage reads in a loop
function badExample() public view returns (uint256) {
uint256 sum = 0;
for (uint i = 0; i < users.length; i++) {
sum += balances[users[i]]; // Multiple storage reads
}
return sum;
}
// GOOD: Cache storage reads
function goodExample() public view returns (uint256) {
uint256 sum = 0;
uint256 length = users.length; // Cache length
address[] memory userList = users; // Cache array
for (uint i = 0; i < length; i++) {
sum += balances[userList[i]];
}
return sum;
}
// BEST: Use calldata for external function parameters
function process(uint256[] calldata ids) external {
// ids are in calldata, no memory allocation needed
for (uint i = 0; i < ids.length; i++) {
// Process ids[i]
}
}
2. Pack Storage Variables
Storage slots are 32 bytes. Pack multiple smaller variables into the same slot:
// BAD: Each variable uses a full storage slot (3 slots total)
uint256 a; // 256 bits
uint256 b; // 256 bits
uint256 c; // 256 bits
// GOOD: Pack into 2 storage slots
uint128 a; // 128 bits ┐
uint128 b; // 128 bits ┘ Same slot (256 bits)
uint256 c; // 256 bits Second slot
This can save significant gas on storage operations.
3. Use Events for Historical Data
Don't store data in state variables if you only need it for historical lookups:
// BAD: Storing full history in state
struct Transaction {
address from;
address to;
uint256 amount;
uint256 timestamp;
}
Transaction[] public transactionHistory; // Expensive!
// GOOD: Emit events instead
event Transfer(address indexed from, address indexed to, uint256 amount, uint256 timestamp);
function transfer(address to, uint256 amount) public {
// ... transfer logic
emit Transfer(msg.sender, to, amount, block.timestamp);
}
Events are much cheaper than storage, and you can still query historical data off-chain.
4. Short-Circuit Evaluations
Order your require statements with cheap checks first:
// BAD: Expensive check first
require(complexComputation(), "Complex check failed");
require(msg.sender == owner, "Not owner");
// GOOD: Cheap check first
require(msg.sender == owner, "Not owner");
require(complexComputation(), "Complex check failed");
If the first check fails, you save gas by not running the second.
5. Batch Operations
Allow users to batch operations in a single transaction:
function batchTransfer(address[] calldata recipients, uint256[] calldata amounts)
external
{
require(recipients.length == amounts.length, "Length mismatch");
for (uint i = 0; i < recipients.length; i++) {
_transfer(msg.sender, recipients[i], amounts[i]);
}
}
This saves the base transaction cost (21,000 gas) for multiple operations.
Testing and Verification
No amount of careful coding can replace thorough testing. Here's my testing strategy:
1. Unit Tests
Test every function in isolation. I use Foundry for this:
function testWithdraw() public {
// Setup
vm.deal(address(contract), 10 ether);
contract.deposit{value: 1 ether}();
// Test
uint256 balanceBefore = address(this).balance;
contract.withdraw(1 ether);
uint256 balanceAfter = address(this).balance;
// Assert
assertEq(balanceAfter - balanceBefore, 1 ether);
}
Aim for 100% code coverage.
2. Fuzz Testing
Fuzz testing automatically generates random inputs to find edge cases:
function testFuzzWithdraw(uint256 amount) public {
vm.assume(amount > 0 && amount <= 100 ether);
contract.deposit{value: amount}();
contract.withdraw(amount);
assertEq(contract.balanceOf(address(this)), 0);
}
Foundry will run this test with hundreds of random amounts, finding edge cases you didn't think of.
3. Integration Tests
Test how your contract interacts with other contracts:
function testIntegrationWithUniswap() public {
// Setup tokens and liquidity
token.approve(address(uniswap), 1000 ether);
uniswap.addLiquidity(address(token), 1000 ether, 10 ether);
// Test your contract's interaction with Uniswap
myContract.buyFromUniswap(1 ether);
// Verify results
assertGt(token.balanceOf(address(myContract)), 0);
}
4. Static Analysis
Use tools to automatically detect common vulnerabilities:
- Slither: Fast, finds many common issues
- Mythril: Deeper analysis, symbolic execution
- Echidna: Property-based testing
Run these regularly during development.
5. Formal Verification
For critical contracts, consider formal verification. This mathematically proves properties about your code:
/// @notice Invariant: Sum of all balances equals total supply
/// @custom:invariant sum(balances) == totalSupply
contract Token {
mapping(address => uint256) public balances;
uint256 public totalSupply;
// Formal verification tools can prove this invariant holds
}
Tools like Certora and Halmos can verify these properties.
The Pre-Deployment Checklist
Before deploying to mainnet, I go through this checklist:
Code Quality:
- All functions have clear documentation
- Complex logic has inline comments explaining why
- No TODO or FIXME comments remain
- Code follows consistent style (use a linter)
Security:
- All external calls follow CEI pattern
- Access control is properly implemented
- Reentrancy guards where needed
- Integer overflow/underflow considered
- Oracle data is validated
- No selfdestruct or delegatecall unless absolutely necessary
Testing:
- 100% code coverage
- Fuzz tests pass
- Integration tests with mainnet forks pass
- Static analysis (Slither) shows no critical issues
- Gas benchmarks are acceptable
Economics:
- Economic incentives are correctly aligned
- No obvious MEV extraction opportunities
- Fee mechanisms work as intended
Audit:
- Internal code review completed
- External audit by reputable firm (if handling significant value)
- All audit findings addressed
Deployment:
- Constructor parameters are correct
- Deployment script is tested on testnet
- Post-deployment verification plan exists
- Emergency pause mechanism (if appropriate)
- Upgrade path (if using proxies)
Real-World Lessons
Let me share some hard-learned lessons:
1. Complexity is the enemy. The most secure contracts I've written are also the simplest. Every feature you add increases the attack surface.
2. Composability creates risk. When your contract interacts with external contracts, you inherit their risks. Be extremely careful with external calls.
3. Economics matter as much as code. Many exploits aren't bugs in the code — they're exploits of the economic mechanisms. Think about incentives carefully.
4. Time pressure kills security. The worst bugs I've seen (and almost shipped) happened when we were rushing to meet a deadline. Build in buffer time.
5. Audits aren't magic. I've seen audited contracts get exploited. Audits are valuable but not sufficient. You still need excellent testing and security practices.
Conclusion
Smart contract development is hard. It requires paranoia, discipline, and constant learning. But it's also incredibly rewarding to build systems that work without trusted intermediaries, handling real value in a secure and efficient way.
The key principles are:
- Assume adversarial conditions and code defensively
- Keep it simple — complexity is the enemy of security
- Test exhaustively — write more test code than application code
- Use established patterns — don't reinvent security mechanisms
- Get audited — external review catches what you miss
The ecosystem has come a long way. We have better tools, better libraries, better practices. But the stakes remain high. Every line of code you write could be the difference between a successful protocol and a headline-making exploit.
Write smart contracts like you're building a bank vault, because in many ways, you are.