Smart Contracts — Code That Is Law
A smart contract is a vending machine made of code, living on Ethereum. It holds funds, enforces rules, and executes automatically when conditions are met — with no company behind it and no way to cheat. But there is a catch the vending machine doesn't have: once deployed, a smart contract is permanent and public. A single bug can drain millions, and there's no "undo." This tutorial covers the full lifecycle of a contract and the security landmines every developer must avoid.
Smart contracts power everything on Ethereum — DeFi, NFTs, DAOs, and stablecoins. But their immutability makes them uniquely unforgiving. History is littered with hacks that drained hundreds of millions because of a single overlooked line. We'll walk through deployment, gas optimization, and the most dangerous vulnerability classes, each with a practical example.
Billions of dollars have been lost to smart contract exploits: The DAO hack (2016) drained $60M via reentrancy; the Parity wallet freeze (2017) locked $280M forever via an access-control bug. Because contracts are immutable and hold real money, a security mistake is not a bug report — it's an irreversible theft. "Move fast and break things" is a catastrophic mindset here.
The Smart Contract Lifecycle
Every smart contract travels through a well-defined lifecycle, from source code to a permanent, running program on-chain. Understanding each stage is the foundation for writing, deploying, and maintaining contracts safely.
Deployment — Birth Of A Contract
Deployment is a special transaction with no recipient (to field empty)
whose data field contains the contract's bytecode. The EVM runs the constructor, stores
the code, and assigns the contract a unique address derived from the deployer's address and nonce.
// A minimal Solidity contract
pragma solidity ^0.8.20;
contract SimpleStorage {
uint256 private value; // state stored on-chain
// constructor runs ONCE at deployment
constructor(uint256 _initial) {
value = _initial;
}
function set(uint256 _v) public {
value = _v;
}
function get() public view returns (uint256) {
return value;
}
}
Before deploying to Ethereum mainnet (where mistakes cost real money), developers deploy to a testnet like Sepolia or Holesky, using free test ETH from a faucet. It behaves identically to mainnet but with worthless coins — the perfect rehearsal stage. Skipping this step is how expensive disasters happen.
Gas Optimization — Writing Efficient Contracts
Every operation in a contract costs gas, and users pay that gas in real ETH. A poorly optimized contract can cost users 10× more per transaction than a lean one. Gas optimization is both a cost-saving art and, sometimes, a security concern (bloated code is harder to audit).
uint128, bool, and address so they share storage
slots. Use calldata over memory for read-only params.
// BAD: two storage writes, reads storage in a loop
function sumBad() public {
for (uint i = 0; i < items.length; i++) {
total = total + items[i]; // SSTORE every loop! costly
}
}
// GOOD: cache in memory, one storage write at the end
function sumGood() public {
uint256 sum = 0; // memory, cheap
uint256 len = items.length; // cache length
for (uint i = 0; i < len; i++) {
sum += items[i];
}
total = sum; // single SSTORE
}
Security Best Practices — The Big Picture
Smart contract security follows a handful of guiding principles. Master these before diving into specific vulnerabilities — most real-world hacks are just violations of one of these rules.
Vulnerability 1 — Reentrancy
Reentrancy is the most infamous smart contract bug. It happens when a contract sends ETH to an external address before updating its own state. The receiving contract can "call back" into the original function repeatedly, draining funds before the balance is ever reduced. This is exactly how The DAO lost $60M in 2016.
// VULNERABLE: sends ETH before updating balance
function withdraw() public {
uint bal = balances[msg.sender];
(bool ok, ) = msg.sender.call{value: bal}(""); // ⚠ external call FIRST
balances[msg.sender] = 0; // too late! attacker re-entered
}
// SAFE: Checks-Effects-Interactions + reentrancy guard
function withdraw() public nonReentrant {
uint bal = balances[msg.sender];
balances[msg.sender] = 0; // update state FIRST
(bool ok, ) = msg.sender.call{value: bal}(""); // interact LAST
require(ok, "transfer failed");
}
Follow Checks-Effects-Interactions: zero out the balance before sending
ETH, so a re-entrant call sees a balance of zero and gets nothing. For extra safety, add a
reentrancy guard (OpenZeppelin's nonReentrant modifier), which
locks the function while it runs. Two simple defenses that would have prevented the $60M DAO hack.
Vulnerability 2 — Integer Overflow & Underflow
Before Solidity 0.8, numbers could silently wrap around. Subtract 1 from a
uint that is 0 and instead of an error you get the maximum possible value — a number
with 78 digits. Attackers exploited this to mint themselves near-infinite tokens or bypass balance
checks.
// VULNERABLE (Solidity < 0.8): silent wrap-around
function transfer(address to, uint amt) public {
balances[msg.sender] -= amt; // ⚠ underflows if amt > balance
balances[to] += amt; // could overflow too
}
// SAFE (Solidity >= 0.8): reverts automatically on overflow
// ...or use OpenZeppelin SafeMath on older versions
function transfer(address to, uint amt) public {
require(balances[msg.sender] >= amt, "insufficient");
balances[msg.sender] -= amt; // safe: 0.8 reverts on underflow
balances[to] += amt;
}
Since Solidity 0.8.0, all arithmetic automatically reverts on overflow
or underflow — the wrap-around bug class is gone by default. On older code, developers used
OpenZeppelin's SafeMath library. Always use a modern compiler version, and never
wrap arithmetic in unchecked { } unless you have proven it cannot overflow.
Vulnerability 3 — Front-Running
Ethereum transactions sit in a public mempool before being mined, visible to everyone. A front-running attacker watches for a profitable pending transaction, then submits their own with a higher gas tip so it gets mined first — jumping ahead to steal the opportunity. This is a huge part of what's now called MEV (Maximal Extractable Value).
minAmountOut) so a front-run trade can't force you
into a terrible rate.
Vulnerability 4 — Timestamp Dependency
Contracts sometimes use block.timestamp for randomness, deadlines, or lottery winners.
But block producers can nudge the timestamp by a few seconds. Any logic that depends
on a precise time — especially "random" number generation — can be manipulated by a validator to
favor themselves.
A validator choosing which block to produce can shift block.timestamp slightly. If
your lottery picks a winner using block.timestamp % players, a malicious validator
can pick a timestamp that makes themselves win. The blockchain has no safe
source of on-chain randomness by itself — use an oracle like Chainlink
VRF for verifiable randomness instead.
// VULNERABLE: validator can bias the "random" winner
function pickWinner() public {
uint winner = block.timestamp % players.length; // ⚠ manipulable
payable(players[winner]).transfer(prize);
}
// SAFE: use Chainlink VRF for tamper-proof randomness
// request randomness from the oracle, then in the callback:
function fulfillRandomWords(uint reqId, uint[] memory rand) internal {
uint winner = rand[0] % players.length; // verifiable, unbiased
payable(players[winner]).transfer(prize);
}
Timestamps are acceptable for coarse checks where a few seconds don't matter — like "is
this deadline more than a day away?" The rule of thumb: never depend on block.timestamp
for anything where a 15-second manipulation could change the outcome or reward.
Vulnerability 5 — Broken Access Control
Access control bugs are among the costliest of all. If a sensitive function — one that withdraws funds, mints tokens, or changes ownership — isn't properly restricted, anyone can call it. The Parity wallet disaster of 2017 froze $280M forever because a critical function lacked proper protection.
// VULNERABLE: anyone can call this and steal everything
function withdrawAll() public { // ⚠ no restriction!
payable(msg.sender).transfer(address(this).balance);
}
// SAFE: guard with an access-control modifier
modifier onlyOwner() {
require(msg.sender == owner, "not authorized");
_;
}
function withdrawAll() public onlyOwner { // only owner passes
payable(owner).transfer(address(this).balance);
}
Don't hand-roll access control. OpenZeppelin's Ownable and AccessControl contracts provide audited, standard patterns for owner-only and role-based permissions. They handle ownership transfer, renouncement, and role management correctly — edge cases that home-grown code routinely gets wrong.
Upgradeable Contracts — Fixing The Unfixable
Contracts are immutable — so how do teams fix bugs or add features? The answer is the proxy pattern. A permanent proxy contract holds all the funds and state but delegates its logic to a separate implementation contract. To upgrade, you simply point the proxy at a new implementation. The address users interact with never changes.
| Fix bugs after launch |
| Add new features over time |
| Keep the same user-facing address |
| State & funds are preserved |
| Admin key can change the rules |
| Reduces trustlessness |
| Storage-layout mistakes corrupt data |
| Bigger attack surface |
The power to upgrade is also the power to change the rules on users. If a single admin key controls the upgrade, that key becomes a honeypot — and a point of centralization that contradicts "trustless" ideals. Serious projects protect upgrades behind a multisig or a timelock + DAO governance, so no single person can swap the logic unilaterally. Use OpenZeppelin's audited proxy libraries and never upgrade without re-auditing.
Golden Rules — Smart Contract Security
unchecked when you have proven arithmetic cannot wrap.