Library
Mastering Ethereum: Building Smart Contracts and DApps · 9 of 15
Mastering Ethereum: Building Smart Contracts and DApps
blockchain CRITICAL

Secure Design Patterns

security patterns defensive-programming best-practices

Key Principle

Deployed contract code is immutable and runs in an adversarial public environment, so a single bug equals permanent monetary loss — the engineering bar is aerospace, not web-widget. The defensive toolkit is structural, not opt-in: "Complexity is the enemy of security." Maximizing reuse of trusted, mature code is the single most fundamental principle, because you cannot patch out a flaw you never originated.

Why This Matters

"Smart contract code is unforgiving. Every bug can lead to monetary loss." There is no undo and no post-deploy patch, so defenses must be chosen up front and proven by survival under attack, not by cleverness.

Good Examples

Defensive programming (five practices)

Each tied to immutability + public adversarial execution:

  • Minimalism — "Complexity is the enemy of security." Fewer lines = fewer bug surfaces; thousands of LOC is itself a red flag.
  • Code reuse — prefer tested libraries (DRY); resist "Not Invented Here."
  • Code quality / rigorous method — bugs aren't patchable post-deploy.
  • Readability / auditability — bytecode is already public and reverse-engineerable, so developing in the open gains audit value and loses nothing.
  • Total input distrust / full test coverage — never assume well-formed input; anyone can supply anything.

Checks-Effects-Interactions

Make external calls (interactions) the last operation, after all state changes (effects), after all preconditions (checks). The primary, structural reentrancy fix: decrement balance before sending, so a reentrant call fails the require guard. "It is good practice for any code that performs external calls to unknown addresses to be the last operation."

Reentrancy guard / mutex

A boolean set true before the external call and false after; require(!mutex) at entry rejects nested calls even if ordering is missed. Redundant with checks-effects-interactions, but a cheap belt-and-suspenders backstop.

Withdrawal (pull-over-push) pattern

Each user calls an isolated withdraw() function to pull their own funds, instead of the contract pushing payouts in a loop. Benefits: (1) a single failed transfer corrupts only that user's interaction, not shared state; (2) it defuses the unbounded-loop DoS — no single tx scales with attacker-controlled length. Same principle underlies both reentrancy defense and DoS avoidance.

DoS avoidance (three patterns)

  • No unbounded loops over user-growable structures. An attacker inflates an array (e.g. investors.push) until the loop's gas exceeds the block gas limit and distribute() is forever uncallable. Fix: withdrawal pattern. (GovernMental: deleting a large mapping exceeded the block gas limit, trapping ~1,100 ether — eventually freed by a 2.5M-gas tx as limits rose.)
  • No single owner key as a chokepoint. If state can only advance when one address acts (e.g. finalize() an ICO), losing the key bricks the system — "the entire operation of the token ecosystem hinges on a single address." Fix: make owner a multisig, or add a time-lock so anyone can advance after a deadline: require(msg.sender == owner || now > unlockTime).
  • No state progression gated on an external party accepting ether. An attacker with no payable fallback makes the send fail forever, so "the contract will never achieve the new state." Fix: add time-based progression.
  • A centralized escape hatch (maintenanceUser) trades the bug for a trust problem — that entity then has power over everyone's funds.

Secure randomness

There is no on-chain entropy; deterministic execution forbids it. Import uncertainty from outside the chain: commit-reveal among peers, RANDAO (changing the trust model to a group of participants), or a centralized/external randomness oracle. Never source randomness from block variables or timestamps.

Use msg.sender, not tx.origin

Bind authorization to the immediate caller. tx.origin lets any contract the victim calls inherit the victim's authority (phishing). Sole legitimate tx.origin use: require(tx.origin == msg.sender) to restrict a function to direct EOA calls.

Security by maturity (OpenZeppelin)

"Perhaps the most fundamental software security principle is to maximize reuse of trusted code" — the crypto adage "Don't roll your own crypto." Novel security-critical code has never faced adversaries, so its bugs are undiscovered; community-vetted libraries have survived adversarial scrutiny. OpenZeppelin is the de facto standard (ERC20/ERC721, crowdsale models, Ownable/Pausable/LimitBalance); ZeppelinOS for upgradeable DApps; ethpm as package index. But on-chain libraries are third-party bytecode you call into — vet before production.

Counterpoints

  • [DATED 2018: the transfer/send 2300-gas stipend was a recommended reentrancy defense, but post-Istanbul/EIP-1884 gas repricings make stipend-gated patterns fragile — current best practice favors call{value:}() with explicit return-value checks plus a reentrancy guard and checks-effects-interactions, not transfer/send.]
  • [DATED 2018: OpenZeppelin remains the reference, but its ERC20 no longer ships SafeMath — Solidity 0.8+ has built-in overflow/underflow checks, so manual SafeMath wrapping is now redundant.]
  • A centralized escape hatch reduces DoS risk but introduces custodial trust — it is a trade-off, not a free win.

Key Quotes

"Complexity is the enemy of security." — Antonopoulos & Wood, Chapter 9

"It is good practice for any code that performs external calls to unknown addresses to be the last operation… known as the checks-effects-interactions pattern." — Antonopoulos & Wood, Chapter 9

"Perhaps the most fundamental software security principle is to maximize reuse of trusted code." — Antonopoulos & Wood, Chapter 9

Rules of Thumb

  • Keep contracts minimal — every line added expands the attack surface.
  • Checks, then effects, then interactions — external calls always last.
  • Add a reentrancy mutex as a backstop even with correct ordering.
  • Pull, don't push: users withdraw their own funds.
  • Never loop over a structure an attacker can grow unbounded.
  • No single-key chokepoints — use multisig or time-locked fallbacks.
  • Never gate state progression on an external party accepting ether.
  • Import randomness (commit-reveal / RANDAO / oracle / VRF); never derive it on-chain.
  • Authorize on msg.sender, not tx.origin.
  • Reuse mature, audited code (OpenZeppelin); don't roll your own.

Related References