Smart Contract Security as a Top Priority
A bug in a web application is annoying. A bug in a smart contract can cost millions — and cannot be fixed after deployment. The immutability that makes blockchains trustworthy also makes mistakes permanent. In this post, we walk through the most common vulnerabilities with vulnerable code and the corresponding fixes.
Reentrancy
Arguably the most well-known vulnerability. An external call returns control to the caller, who re-enters the function before state is updated.
Vulnerable:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableVault {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// BUG: External call BEFORE state update
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0; // Too late!
}
}
Fix — Checks-Effects-Interactions pattern:
contract SecureVault {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// State update BEFORE external call
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Additionally, we recommend using a reentrancy guard:
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract GuardedVault is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Access Control
Missing or faulty access control is the most common cause of exploits in
practice. Often it is simply a missing onlyOwner modifier on a
critical function.
Vulnerable:
contract VulnerableToken {
mapping(address => uint256) public balances;
// BUG: Anyone can mint arbitrary amounts
function mint(address to, uint256 amount) external {
balances[to] += amount;
}
}
Fix with OpenZeppelin AccessControl:
import "@openzeppelin/contracts/access/AccessControl.sol";
contract SecureToken is AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
mapping(address => uint256) public balances;
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
balances[to] += amount;
}
}
For DAO contracts, we prefer role-based access control over simple
Ownable, since DAOs typically have multiple actors with different
permissions.
Integer Overflow (pre-Solidity 0.8)
Since Solidity 0.8, arithmetic operations are overflow-protected by default.
However, we regularly encounter contracts in audits that use
unchecked blocks to save gas — sacrificing safety:
// Safe only if you can PROVE no overflow occurs
function unsafeIncrement(uint256 x) internal pure returns (uint256) {
unchecked { return x + 1; } // OK: uint256 max is astronomical
}
// DANGEROUS: Can overflow with arbitrary inputs
function unsafeMultiply(uint256 a, uint256 b) internal pure returns (uint256) {
unchecked { return a * b; } // BUG: Overflow possible
}
Rule: only use unchecked for loop counters and increment operations
where overflow is mathematically impossible.
Front-Running and Sandwich Attacks
Every transaction is visible in the mempool before it is included in a block. An attacker can see your transaction and place their own before it. For governance votes, this means an attacker could buy tokens, vote, and sell immediately after.
Countermeasures:
// Commit-Reveal pattern for voting
contract SecureVoting {
mapping(bytes32 => bool) public commits;
mapping(address => bytes32) public userCommits;
// Phase 1: Commit (hash of vote)
function commitVote(bytes32 hash) external {
userCommits[msg.sender] = hash;
commits[hash] = true;
}
// Phase 2: Reveal (vote + salt)
function revealVote(uint256 proposalId, bool support, bytes32 salt)
external
{
bytes32 hash = keccak256(
abi.encodePacked(proposalId, support, salt, msg.sender)
);
require(userCommits[msg.sender] == hash, "Invalid reveal");
delete userCommits[msg.sender];
// Process vote ...
}
}
Audit Process
Our internal security review before every deployment follows a fixed schema:
1. Static analysis with Slither:
$ slither src/Vault.sol --filter-paths "node_modules"
# Checks for known patterns: reentrancy, unused
# return values, shadowing, etc.
2. Unit tests with Foundry:
function test_ReentrancyProtection() public {
AttackerContract attacker = new AttackerContract(address(vault));
// Attacker deposits and attempts reentrancy
vm.deal(address(attacker), 1 ether);
attacker.deposit{value: 1 ether}();
vm.expectRevert("ReentrancyGuard: reentrant call");
attacker.attack();
}
3. Fuzzing with Foundry:
function testFuzz_WithdrawNeverExceedsBalance(
address user,
uint256 depositAmount
) public {
vm.assume(user != address(0));
vm.assume(depositAmount > 0 && depositAmount <= 100 ether);
vm.deal(user, depositAmount);
vm.prank(user);
vault.deposit{value: depositAmount}();
uint256 vaultBalanceBefore = address(vault).balance;
vm.prank(user);
vault.withdraw();
assertGe(vaultBalanceBefore, depositAmount);
}
4. External audit — For contracts managing significant value, we engage external auditors. An internal review does not replace an external audit, because your own assumptions are rarely challenged by your own developers.
Checklist
Our quick checklist for every smart contract deployment:
- Checks-Effects-Interactions followed?
- Reentrancy guard on all external calls?
- Access control on all state-changing functions?
uncheckedonly where mathematically proven safe?- Slither clean of High/Medium findings?
- 100% branch coverage in tests?
- Fuzz tests for all public functions?
- External audit for value custody?
Smart contract security is not a feature you bolt on at the end. It must be considered from the very first line of code. The cost of an audit is a fraction of what an exploit can cost.