Introduction: Why Most Smart Contracts Fail Before They Launch
Smart contract development is fundamentally different from traditional software engineering, and most developers learn this the hard way. I've audited hundreds of contracts across Ethereum, Solana, and Sui, and the pattern is always the same: teams rush to ship features without understanding the architectural foundations that make contracts secure, upgradeable, and gas-efficient. The result? Contracts that hemorrhage funds, lock assets permanently, or cost users hundreds of dollars per transaction. According to Chainalysis, over $3.8 billion was lost to DeFi exploits in 2022 alone, with the majority stemming from poor architectural decisions made during the design phase.
The fundamental challenge is that smart contracts are immutable financial instruments operating in an adversarial environment. Unlike traditional applications where you can patch bugs with a quick deployment, a flawed smart contract can mean permanent loss of funds. Each blockchain—Ethereum, Solana, and Sui—has evolved distinct architectural patterns based on their underlying execution models. Ethereum's account-based model with the EVM, Solana's parallel transaction processing, and Sui's object-centric approach each demand different design patterns. Understanding these patterns isn't just academic; it's the difference between a contract that gets exploited in its first week and one that securely manages billions in TVL.
The Foundation: Understanding Execution Models Before Patterns
Before diving into specific patterns, you need to understand how each blockchain executes transactions, because this fundamentally shapes which patterns work and which become anti-patterns. Ethereum uses an account-based model where contracts maintain state in storage slots and all transactions execute sequentially. This means every state change is ordered, but it also creates serious constraints around gas costs and upgradability. When you call a function on Ethereum, you're modifying shared mutable state, which is why reentrancy attacks exist and why the Checks-Effects-Interactions pattern became critical.
Solana takes a radically different approach with its parallel transaction processing engine. Transactions declare which accounts they'll read and write upfront, allowing the runtime to execute non-conflicting transactions simultaneously. This architectural choice means Solana contracts must be designed with explicit account ownership and careful state management. The Program Derived Address (PDA) pattern emerged specifically to handle deterministic account generation in this model. Cross-Program Invocations (CPIs) work differently than Ethereum's DELEGATECALL, requiring different security considerations. A contract that works perfectly on Ethereum might create race conditions on Solana if you don't account for parallel execution.
Sui represents the newest evolution, built around an object-centric model using the Move programming language. In Sui, everything is an object with a unique ID, and objects have explicit ownership—either owned by an address, shared mutably, shared immutably, or owned by another object. This ownership model eliminates entire classes of vulnerabilities that plague Ethereum. Transactions operate on objects directly, and Sui's validator network can process transactions touching different objects in parallel without conflict. The capability pattern in Move, where you pass around unforgeable tokens to prove ownership, eliminates the need for many access control checks that bloat Ethereum contracts.
Core Architectural Patterns: The Universal Principles
Certain architectural patterns transcend specific blockchains because they address fundamental problems in decentralized systems. The Proxy Pattern is essential for upgradability across all chains, though implementation differs dramatically. On Ethereum, you typically use EIP-1967 transparent proxies or UUPS (Universal Upgradeable Proxy Standard) proxies that delegate calls to an implementation contract. The proxy stores state while the logic contract can be swapped. Here's a minimal UUPS proxy implementation:
// Ethereum UUPS Proxy Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract UUPSProxy {
// Storage slot for implementation address (EIP-1967)
bytes32 private constant IMPLEMENTATION_SLOT =
bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
constructor(address _implementation) {
_setImplementation(_implementation);
}
function _setImplementation(address newImplementation) private {
assembly {
sstore(IMPLEMENTATION_SLOT, newImplementation)
}
}
function _implementation() internal view returns (address impl) {
assembly {
impl := sload(IMPLEMENTATION_SLOT)
}
}
fallback() external payable {
address impl = _implementation();
assembly {
// Copy calldata to memory
calldatacopy(0, 0, calldatasize())
// Delegate call to implementation
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
// Copy return data
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
On Solana, upgradability is built into the runtime through the BPF loader, but you still need architectural patterns for data migration. The typical approach uses separate program and data accounts, with versioned data structures. You might maintain a program authority that can upgrade the program binary, but more sophisticated patterns involve progressive upgrades with feature flags. Here's how you might structure a Solana program for safe upgrades:
// Solana Versioned Data Pattern
use borsh::{BorshDeserialize, BorshSerialize};
#[derive(BorshSerialize, BorshDeserialize)]
pub enum AccountVersion {
V1(AccountDataV1),
V2(AccountDataV2),
}
#[derive(BorshSerialize, BorshDeserialize)]
pub struct AccountDataV1 {
pub version: u8,
pub owner: Pubkey,
pub balance: u64,
}
#[derive(BorshSerialize, BorshDeserialize)]
pub struct AccountDataV2 {
pub version: u8,
pub owner: Pubkey,
pub balance: u64,
pub last_updated: i64, // New field
}
pub fn migrate_account(account: &mut Account) -> Result<()> {
let data = AccountVersion::try_from_slice(&account.data.borrow())?;
match data {
AccountVersion::V1(v1_data) => {
let v2_data = AccountDataV2 {
version: 2,
owner: v1_data.owner,
balance: v1_data.balance,
last_updated: Clock::get()?.unix_timestamp,
};
let serialized = AccountVersion::V2(v2_data).try_to_vec()?;
account.data.borrow_mut()[..serialized.len()].copy_from_slice(&serialized);
}
AccountVersion::V2(_) => {
// Already upgraded
}
}
Ok(())
}
The Factory Pattern is another universal architectural approach, used to deploy multiple instances of contracts with standardized interfaces. On Ethereum, factories deploy new contracts using CREATE2 for deterministic addresses. On Solana, factories create new program-derived addresses with specific seeds. On Sui, factories mint new objects with capabilities. The pattern serves the same purpose—controlled creation of new instances—but the implementation reflects each chain's execution model. Security considerations also vary: Ethereum factories need to prevent front-running of CREATE2 deployments, Solana factories must validate PDA bumps, and Sui factories should properly scope capabilities.
Ethereum-Specific Patterns: EVM Constraints and Solutions
Ethereum's constraints have spawned the richest ecosystem of design patterns because developers have been solving these problems longest. The Checks-Effects-Interactions (CEI) pattern is non-negotiable for preventing reentrancy attacks. You verify conditions first (checks), update your contract's state second (effects), then interact with external contracts last (interactions). This ordering prevents the infamous DAO hack pattern where external calls reenter before state updates complete. Here's the pattern in practice:
// Checks-Effects-Interactions Pattern
contract SecureVault {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
// CHECKS: Verify conditions before any state changes
require(balances[msg.sender] >= amount, "Insufficient balance");
require(amount > 0, "Must withdraw positive amount");
// EFFECTS: Update state before external calls
balances[msg.sender] -= amount;
// INTERACTIONS: External calls come last
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
// WRONG: This is vulnerable to reentrancy
function withdrawVulnerable(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
// External call BEFORE state update - vulnerable!
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount; // Too late
}
}
The Pull Over Push pattern is essential for handling failed transfers safely. Instead of pushing funds to recipients (which can fail or trigger malicious code), you let them pull their funds when ready. This pattern prevents denial-of-service attacks where a single failing recipient blocks payments to everyone. Combined with OpenZeppelin's ReentrancyGuard, you get robust payment handling:
contract SecurePayments {
mapping(address => uint256) public pendingWithdrawals;
// Pull pattern: Users withdraw their own funds
function withdraw() external nonReentrant {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "No funds to withdraw");
pendingWithdrawals[msg.sender] = 0; // Effects before interactions
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
// Internal function to credit payments
function _creditPayment(address recipient, uint256 amount) internal {
pendingWithdrawals[recipient] += amount;
}
}
Gas optimization patterns are critical on Ethereum where transaction costs directly impact user adoption. The most impactful optimizations involve storage layout, using events for data that doesn't need on-chain queries, and batching operations. Storage slots cost 20,000 gas to initialize and 5,000 gas to update, so packing multiple values into single slots saves substantially. Using uint128 instead of uint256 when possible allows two values per slot. Mapping values to a single struct and updating multiple fields in one transaction amortizes the storage access cost.
The Emergency Stop pattern (Circuit Breaker) is essential for production contracts. You need the ability to pause critical functions if a vulnerability is discovered. However, this creates centralization risks, so the pattern should be combined with timelock governance and transparent monitoring. OpenZeppelin's Pausable contract provides the foundation, but you need careful thought about what should and shouldn't be pausable—users must always be able to withdraw their own funds.
Solana-Specific Patterns: Parallel Processing and Account Architecture
Solana's architecture demands different patterns because of its parallel execution model and account-based data storage. The Program Derived Address (PDA) pattern is fundamental—it allows programs to deterministically generate addresses they control without needing private keys. PDAs enable protocols to custody user funds and create hierarchical account structures. The pattern involves finding a valid PDA by combining seeds with a bump value:
# Solana PDA Pattern in Python
from solana.publickey import PublicKey
def find_program_address(seeds: list[bytes], program_id: PublicKey) -> tuple[PublicKey, int]:
"""
Find a valid PDA for the given seeds and program.
Returns (address, bump) where bump makes the address off-curve.
"""
for bump in range(256, 0, -1):
try:
seeds_with_bump = seeds + [bytes([bump])]
address = PublicKey.create_program_address(seeds_with_bump, program_id)
return (address, bump)
except:
continue
raise Exception("Unable to find valid PDA")
# Example: Create a user vault PDA
program_id = PublicKey("YourProgramID...")
user_pubkey = PublicKey("UserPublicKey...")
vault_pda, bump = find_program_address(
[b"vault", bytes(user_pubkey)],
program_id
)
# In Rust program code, verify the PDA:
# let (expected_pda, bump) = Pubkey::find_program_address(
# &[b"vault", user.key.as_ref()],
# program_id
# );
# require!(vault.key == &expected_pda, ErrorCode::InvalidPDA);
The Associated Token Account (ATA) pattern extends PDAs specifically for SPL tokens, creating deterministic addresses for user token accounts. Every user gets one ATA per token mint, eliminating the chaos of users creating multiple token accounts. The pattern is now standardized, but understanding it is crucial for building token programs. You derive ATAs using the owner's address, the token mint, and the SPL Token program ID as seeds.
Cross-Program Invocation (CPI) patterns enable composability but require careful security considerations. Unlike Ethereum's DELEGATECALL which executes in the caller's context, Solana CPIs execute in the called program's context with explicit signer privileges passed through. The security model is different: you must explicitly verify that the calling program has authority before performing privileged operations. Here's a safe CPI pattern:
# Solana CPI Pattern in Python (using Anchor framework concepts)
from solana.transaction import AccountMeta, TransactionInstruction
from solana.publickey import PublicKey
def create_transfer_cpi(
token_program: PublicKey,
from_account: PublicKey,
to_account: PublicKey,
authority: PublicKey,
amount: int,
signer_seeds: list[bytes]
) -> TransactionInstruction:
"""
Create a CPI instruction for SPL token transfer.
The calling program must sign with PDA authority.
"""
accounts = [
AccountMeta(pubkey=from_account, is_signer=False, is_writable=True),
AccountMeta(pubkey=to_account, is_signer=False, is_writable=True),
AccountMeta(pubkey=authority, is_signer=True, is_writable=False),
]
# Instruction data: [instruction_discriminator, amount]
data = bytes([3]) + amount.to_bytes(8, 'little')
return TransactionInstruction(
keys=accounts,
program_id=token_program,
data=data
)
# In actual Rust code, invoke with PDA signer:
# invoke_signed(
# &instruction,
# &accounts,
# &[&signer_seeds] // PDA seeds for signing
# )?;
Account validation patterns are absolutely critical on Solana because the runtime doesn't enforce type safety—every account is just bytes. You must validate that accounts are owned by the expected programs, have sufficient lamports, and contain valid data. The Anchor framework codifies these patterns with account constraints, but understanding the underlying checks is essential. Always verify account ownership, validate PDA derivations, check for initialization, and ensure proper account sizes.
The rent-exemption pattern ensures accounts remain persistent. Solana charges rent on accounts unless they maintain a minimum balance (rent-exempt threshold). Your program should always require accounts to be rent-exempt to prevent data loss. Calculate rent-exempt minimums based on account size and require users to fund accounts appropriately during initialization.
Sui-Specific Patterns: Object-Centric Architecture and Move Capabilities
Sui's object-centric model and Move language enable patterns that eliminate entire vulnerability classes while introducing new architectural possibilities. The Capability pattern is foundational—instead of checking access control in every function, you pass around unforgeable capability objects that prove authority. This is type-safe and impossible to counterfeit because Move's resource semantics prevent copying or dropping capabilities unless explicitly allowed:
// Sui Move Capability Pattern
module example::admin_cap {
use sui::object::{Self, UID};
use sui::transfer;
use sui::tx_context::{Self, TxContext};
/// Capability object proving admin authority
struct AdminCap has key, store {
id: UID,
}
/// Protected resource that requires AdminCap
struct ProtectedVault has key {
id: UID,
balance: u64,
}
/// Initialize and transfer AdminCap to sender
fun init(ctx: &mut TxContext) {
let admin_cap = AdminCap {
id: object::new(ctx),
};
transfer::transfer(admin_cap, tx_context::sender(ctx));
}
/// Only callable if you possess AdminCap
public entry fun withdraw(
_admin_cap: &AdminCap, // Proves admin authority
vault: &mut ProtectedVault,
amount: u64,
ctx: &mut TxContext
) {
// No access control check needed - possession of AdminCap is proof
assert!(vault.balance >= amount, 0);
vault.balance = vault.balance - amount;
// Transfer coins to sender...
}
/// Can't withdraw without AdminCap - compiler prevents it
// public entry fun withdraw_without_cap(vault: &mut ProtectedVault) {
// // This function can't access vault.balance for withdrawal
// }
}
The Shared Object pattern allows multiple transactions to access the same object, but with important tradeoffs. Shared mutable objects require consensus for each transaction touching them, limiting throughput. The pattern is necessary for orderbook exchanges, AMMs, and other protocols requiring global state, but you should minimize shared objects where possible. For many use cases, owned objects with transfer capabilities provide better performance. Here's how to design for both models:
// Sui Shared vs Owned Object Patterns
module example::liquidity_pool {
use sui::object::{Self, UID};
use sui::transfer;
use sui::coin::{Self, Coin};
use sui::balance::{Self, Balance};
use sui::tx_context::TxContext;
/// Shared pool object - requires consensus for all operations
struct Pool has key {
id: UID,
token_a: Balance<TokenA>,
token_b: Balance<TokenB>,
}
/// Create shared pool - expensive consensus for every swap
public fun create_shared_pool(ctx: &mut TxContext) {
let pool = Pool {
id: object::new(ctx),
token_a: balance::zero(),
token_b: balance::zero(),
};
transfer::share_object(pool);
}
/// LP token - owned object, no consensus needed to hold/transfer
struct LPToken has key, store {
id: UID,
pool_id: ID,
shares: u64,
}
/// Issue LP token to provider - owned object for better performance
public fun mint_lp_token(
pool: &Pool,
shares: u64,
ctx: &mut TxContext
): LPToken {
LPToken {
id: object::new(ctx),
pool_id: object::id(pool),
shares,
}
}
/// Redeem LP token - requires shared pool but LP token is owned
public entry fun redeem(
pool: &mut Pool,
lp_token: LPToken,
ctx: &mut TxContext
) {
let LPToken { id, pool_id, shares } = lp_token;
assert!(pool_id == object::id(pool), 0);
// Calculate redemption amounts...
// Transfer tokens to sender...
object::delete(id);
}
}
The Hot Potato pattern leverages Move's resource semantics to enforce specific operation sequences. You create a resource without the store or drop abilities, meaning it can't be stored in global storage or discarded. The only way to get rid of it is to pass it to a specific function that consumes it. This forces users through a multi-step flow:
// Sui Hot Potato Pattern
module example::flash_loan {
use sui::balance::{Self, Balance};
use sui::coin::{Self, Coin};
/// Hot potato - no key, store, or drop abilities
/// Must be consumed by repay function
struct FlashLoan<phantom T> {
amount: u64,
}
/// Borrow coins - returns coins AND hot potato
public fun borrow<T>(
pool: &mut Pool<T>,
amount: u64,
ctx: &mut TxContext
): (Coin<T>, FlashLoan<T>) {
let borrowed = coin::take(&mut pool.reserve, amount, ctx);
let receipt = FlashLoan { amount };
(borrowed, receipt)
}
/// Must be called to consume hot potato
/// This enforces repayment in same transaction
public fun repay<T>(
pool: &mut Pool<T>,
payment: Coin<T>,
receipt: FlashLoan<T>
) {
let FlashLoan { amount } = receipt;
// Verify repayment with fee
let payment_value = coin::value(&payment);
let fee = amount / 1000; // 0.1% fee
assert!(payment_value >= amount + fee, 0);
coin::put(&mut pool.reserve, payment);
}
// User must call both borrow and repay in single transaction:
// let (coins, receipt) = flash_loan::borrow(&mut pool, 1000, ctx);
// // ... do something with coins ...
// flash_loan::repay(&mut pool, coins, receipt);
}
Object wrapping patterns enable complex hierarchies and access control. Child objects can be wrapped inside parent objects, making them inaccessible except through the parent's interface. This is powerful for creating vaults, escrows, and controlled asset management. The dynamic field pattern extends this by allowing objects to own key-value mappings of other objects without knowing the types at compile time, enabling extensible designs.
Security Patterns Across All Chains: Defense in Depth
Security patterns transcend specific blockchains because they address fundamental adversarial threats. The Access Control pattern is universal but implemented differently on each chain. On Ethereum, you typically use modifiers with OpenZeppelin's AccessControl or Ownable contracts. On Solana, you validate signer accounts and check account ownership. On Sui, you use capability objects. Regardless of implementation, the principle is the same: explicitly verify authorization before privileged operations. Never rely on implicit assumptions about who can call functions.
Integer overflow protection is critical despite Solidity 0.8+ having built-in checks. You still need to understand overflow behavior when interfacing with older contracts or when using unchecked blocks for gas optimization. On Solana with Rust, you use checked arithmetic or explicitly handle overflows. The pattern is simple: never assume arithmetic operations succeed, always validate results or use safe math libraries.
Oracle manipulation prevention requires architectural thinking about price discovery. Don't use spot prices from single DEXes—attackers can manipulate these with flash loans. Use time-weighted average prices (TWAP), multiple oracle sources, or Chainlink-style decentralized oracles. The pattern involves sampling prices over time windows and requiring multiple confirmations before acting on price data:
# Price Oracle Pattern - TWAP Implementation
from dataclasses import dataclass
from typing import List
@dataclass
class PriceObservation:
timestamp: int
price: int
cumulative_price: int
class TWAPOracle:
def __init__(self, window_size: int = 1800): # 30 minute window
self.window_size = window_size
self.observations: List[PriceObservation] = []
def update(self, current_time: int, spot_price: int):
"""Update oracle with new price observation"""
if len(self.observations) == 0:
cumulative = 0
else:
last_obs = self.observations[-1]
time_elapsed = current_time - last_obs.timestamp
cumulative = last_obs.cumulative_price + (last_obs.price * time_elapsed)
obs = PriceObservation(
timestamp=current_time,
price=spot_price,
cumulative_price=cumulative
)
self.observations.append(obs)
# Remove observations outside window
cutoff = current_time - self.window_size
self.observations = [o for o in self.observations if o.timestamp > cutoff]
def get_twap(self, current_time: int) -> int:
"""Calculate TWAP over the window"""
if len(self.observations) < 2:
raise Exception("Insufficient observations")
# Find oldest observation in window
oldest = self.observations[0]
latest = self.observations[-1]
time_elapsed = latest.timestamp - oldest.timestamp
if time_elapsed == 0:
return latest.price
cumulative_delta = latest.cumulative_price - oldest.cumulative_price
return cumulative_delta // time_elapsed
def is_price_safe(self, current_time: int, spot_price: int, max_deviation: int = 10) -> bool:
"""Check if spot price is within acceptable range of TWAP"""
try:
twap = self.get_twap(current_time)
deviation = abs(spot_price - twap) * 100 // twap
return deviation <= max_deviation
except:
return False
Rate limiting patterns prevent abuse and spam. On Ethereum, you can use block timestamps to enforce cooldowns between user actions. On Solana, you might use account-level nonces or timestamp checks. On Sui, you can store timestamp in owned objects. The pattern involves tracking the last action time and requiring minimum intervals between operations. This prevents MEV bots from spamming transactions and protects against certain economic attacks.
Front-running protection requires careful design because transaction ordering is adversarial. Use commit-reveal schemes for sensitive operations where users first commit to an action with a hash, then reveal the details in a later transaction. For DEX trades, implement slippage protection and minimum output amounts. Consider using private mempools or Flashbots-style solutions for particularly sensitive operations. The architectural principle is: assume attackers see your transaction before it executes and design accordingly.
The 80/20 Rule: 20% of Knowledge That Delivers 80% of Results
Most smart contract vulnerabilities stem from a small set of fundamental mistakes. Master these core principles and you'll avoid the vast majority of critical issues. First, understand state management: always update state before external calls (CEI pattern), never assume state remains unchanged during external calls, and use reentrancy guards on functions that transfer value or call external contracts. This single principle prevents reentrancy attacks, which have caused billions in losses including the DAO hack, various flash loan exploits, and cross-function reentrancy attacks.
Second, get access control right from the start. Every privileged function needs explicit authorization checks, whether through modifiers, account validation, or capability objects. Use established libraries like OpenZeppelin's AccessControl rather than rolling your own. Implement the principle of least privilege—give the minimum permissions necessary. Use multi-sig wallets or timelock governance for critical admin functions rather than single EOA ownership. Access control bugs account for roughly 30% of all high-severity smart contract vulnerabilities according to recent audit data.
Third, validate all inputs and trust no external data. Assume every parameter can be malicious, check array bounds before accessing elements, validate addresses are non-zero when expected, ensure amounts are positive and within reasonable ranges, and verify signatures cryptographically rather than trusting claimed identities. On Solana specifically, validate that accounts are owned by expected programs and that PDAs are correctly derived. On Sui, verify capability objects and object ownership. Input validation prevents injection attacks, economic exploits through extreme values, and unauthorized access through fake credentials.
Fourth, design for failure and include emergency mechanisms. Build circuit breakers that can pause critical functions if issues are detected, create upgrade paths that allow fixing bugs without full redeployment, implement rate limits to bound the impact of exploits, provide clear paths for users to recover funds even when contracts are paused, and monitor on-chain activity for anomalies. The key insight is that perfect security is impossible—you need defense in depth with multiple layers protecting users even when one layer fails.
Fifth, understand the economic incentives and game theory of your contract. Think like an attacker: if someone had advance knowledge of a transaction, what could they profit from? If someone could manipulate prices or oracle data, what would be the most profitable attack? If someone could front-run or sandwich trades, where's the value extraction? Design mechanisms that align incentives correctly and make attacks unprofitable even when technically possible. Consider MEV extraction opportunities and either prevent them or capture that value for users through mechanisms like MEV auctions or batch settlement.
These five principles—proper state management, correct access control, comprehensive input validation, failure resilience, and economic security—form the foundation. A contract that gets these right will be more secure than 80% of contracts in production, even without exotic security patterns or extensive formal verification. Focus relentlessly on these fundamentals before adding complexity.
Key Takeaways: 5 Critical Actions for Production-Ready Contracts
Action 1: Implement Checks-Effects-Interactions in Every State-Changing Function Start by auditing every function that modifies state or transfers value. Restructure them to follow CEI religiously: verify all conditions and requirements first, update all internal state variables second, and only then make external calls or transfer assets. Add reentrancy guards using OpenZeppelin's ReentrancyGuard on Ethereum or equivalent protections on other chains. Create a checklist and mark each function as you verify the pattern. This single action prevents the most catastrophic exploit class.
Action 2: Build Comprehensive Test Suites Including Attack Scenarios Don't just test happy paths—write tests that try to break your contract. Test reentrancy attacks by creating malicious receiver contracts, test integer overflows with boundary values, test access control by calling from unauthorized accounts, test front-running scenarios with transaction reordering, and test economic attacks with extreme price movements. Aim for >90% code coverage including branches. Use fuzzing tools like Echidna or Foundry's fuzzer to find edge cases you didn't think of. On Solana, test with various account configurations and ownership scenarios. On Sui, test capability object handling and ownership transfers.
Action 3: Implement Tiered Access Control with Timelock Governance Replace single-owner admin patterns with proper multi-sig governance. Use Gnosis Safe on Ethereum, Squads on Solana, or equivalent multi-sig solutions. Add timelock delays for critical operations so the community has warning before changes execute. Implement role-based access control with separate roles for different functions (upgrader, pauser, fee adjuster, etc.). Document all privileged functions and their authorization requirements clearly. This makes your contract more trustworthy and prevents single points of failure.
Action 4: Add Circuit Breakers and Emergency Procedures Implement pausable functionality for critical operations but ensure users can always withdraw their own funds even when paused. Create clear escalation procedures documented in your code and external documentation. Set up monitoring with alerts for unusual activity (large withdrawals, unusual gas usage, unexpected state changes). Have a tested emergency response plan including how to communicate with users, steps to investigate issues, criteria for triggering pauses, and processes for remediation. Test these procedures with drills before you need them.
Action 5: Get Multiple Independent Security Audits Budget for at least two audits from different firms before mainnet launch. Auditors have different specialties and blind spots, so multiple perspectives catch more issues. Schedule audits after your code is feature-complete but before mainnet launch—aim for 4-6 weeks of audit time. Fix all critical and high-severity findings, carefully evaluate medium findings, and document your reasoning for any findings you don't fix. Run automated analysis tools like Slither, Mythril, or Securify before audits to catch obvious issues. After audits, implement a bug bounty program through platforms like Immunefi to crowdsource ongoing security review. Budget 10-20% of your raise for security—it's cheaper than getting exploited.
Memory Aids: Analogies and Examples for Pattern Recall
Think of the Checks-Effects-Interactions pattern like a bank withdrawal: the teller first checks your ID and account balance (checks), then updates your account balance in their system (effects), and only after that hands you the cash (interactions). If they gave you cash first, you could run to another teller before the system updated and withdraw twice. This is exactly what happens in reentrancy attacks—the contract "hands over cash" before updating its records.
The Proxy pattern is like a receptionist at a company. The receptionist (proxy) has a permanent address and phone number, but they can redirect your call to different departments (implementation contracts) based on current needs. When the company reorganizes departments, the receptionist's number stays the same—they just redirect to new extensions. Users always call the same address, but the logic behind it can change. The receptionist doesn't do the actual work, they just forward your request to whoever currently handles it.
Program Derived Addresses on Solana are like safe deposit boxes at a bank. The box number (PDA) is determined by combining your ID with the box purpose written on the rental form (seeds). Anyone can calculate what box number you should have, but only you can access it because the bank (program) verifies your authorization. You can't pick a random box number—it's mathematically determined. This prevents conflicts and ensures everyone's stuff ends up in the right place.
Capability objects in Sui are like physical keys. If you have the key to a car, you can drive it—there's no need for someone to check your driver's license every time. The key itself is proof of authorization. Keys can't be duplicated (Move's type system prevents copying), and you can hand your key to someone else to let them drive. This is fundamentally more efficient than calling the DMV every time someone wants to start the car (traditional access control checking).
The Hot Potato pattern is like a bomb defusal scenario in movies. Someone hands you an armed bomb (resource without drop ability) and says "you have 60 seconds to defuse this" (must be consumed by specific function). You can't just put it down and walk away (no drop), you can't store it in a safe (no store), and you can't give it to someone else to handle. You must complete the defusal sequence in that moment or the transaction explodes (reverts). Flash loans use this pattern—you borrow funds but MUST repay in the same transaction or everything fails.
TWAP oracles are like taking someone's average mood over a week rather than judging them by a single moment. If someone has a bad day, their one-week average is barely affected. An attacker trying to manipulate price is like someone trying to fake being happy all week—they can fake a moment, but faking sustained behavior is expensive. The longer your window, the more expensive manipulation becomes. Spot prices are like judging someone's entire personality from one facial expression—easily misleading.
Circuit breakers are exactly like electrical circuit breakers in your home. When too much current flows (unusual activity detected), the breaker trips and cuts power to prevent the house from burning down (prevents loss of funds). You can reset it manually once you've identified and fixed the problem (unpause after investigation). Good circuit breakers are specific—one breaker for the kitchen, another for the bedroom—so a problem in one area doesn't shut down your whole house. Similarly, pause mechanisms should be granular: pause deposits but not withdrawals, pause trading but not admin functions.
The Factory pattern is like a cookie cutter. You have one carefully designed cutter (factory contract), and you use it to create many identical cookies (new contract instances). Each cookie can have different decorations (parameters), but they all have the same basic shape (interface). If you discover your cookie cutter makes cookies that are too thick, you can make a new cutter (new factory version) without throwing away all the cookies you already made.
Common Anti-Patterns: What NOT to Do
Understanding what to avoid is as important as knowing best practices. The God Contract anti-pattern is tragically common: a single contract handling too many responsibilities, making it impossible to audit, expensive to deploy, and difficult to upgrade. I've seen contracts with 50+ functions mixing token logic, governance, rewards distribution, and access control in one 3000-line file. The solution is separation of concerns—break functionality into focused, composable contracts. Core token logic in one contract, governance in another, staking in a third. Use interfaces to connect them. Each contract should do one thing well.
Tight coupling between contracts creates fragile systems. If Contract A directly calls functions on Contract B with hardcoded addresses, you can't upgrade B without breaking A. Instead, use interfaces and updatable registry patterns. Store addresses in configuration that can be modified by governance rather than hardcoding them. This is the difference between components that can evolve versus systems that ossify into unmaintainable messes.
The timestamp dependence anti-pattern uses block.timestamp for critical security decisions. Block timestamps can be manipulated by miners/validators within ~15 seconds on Ethereum, more on other chains. Never use timestamps for randomness, for precise timing requirements in financial applications, or as the sole factor in access control. Use block numbers for deterministic timing or oracle-provided random values from Chainlink VRF. Timestamps are fine for rough time windows (>1 hour) but not for precise timing.
Unbounded loops are a critical anti-pattern, especially on Ethereum where gas limits can cause transactions to fail. Never loop over user-provided arrays without size limits, never iterate through growing data structures without pagination, and avoid patterns where gas costs grow with user count. Design for O(1) operations when possible. For example, instead of looping through all stakers to distribute rewards, use a pull-based pattern where users claim their rewards individually. Instead of iterating through a list to find an item, use mappings for O(1) lookup.
The float/percentage precision anti-pattern causes loss of funds through rounding. Never use floating point—blockchains only handle integers. Represent percentages as basis points (1/10000ths) or use fixed-point arithmetic with scaling factors. For example, represent 0.3% as 30 basis points, or multiply by 10000 for calculations then divide back. Always round in favor of the protocol, never the user, when handling division remainders. Consider using libraries like PRBMath for precise fixed-point math.
Missing event emissions make contracts impossible to monitor and integrate with. Always emit events for state changes, especially value transfers, ownership changes, configuration updates, and critical operations. Events are your contract's audit log. Front-ends depend on them, monitoring systems need them, and they're much cheaper than storage. The pattern: every state variable change should have a corresponding event.
Approval/transfer race conditions plague token contracts. The standard ERC20 approve/transferFrom pattern has a race condition: if you approve someone for 100 tokens then want to change it to 50, they can front-run your approval change and spend 150 total. The solution is to always set approval to 0 first, or use increaseAllowance/decreaseAllowance functions. Better yet, use permit functions (EIP-2612) that handle approvals atomically with signatures.
Testing and Verification Strategies: Beyond Basic Coverage
High code coverage means nothing if your tests don't reflect adversarial scenarios. Property-based testing using tools like Echidna or Foundry's fuzzer is essential. Instead of writing specific test cases, you define invariants that must always hold, then let the fuzzer generate thousands of random inputs trying to break those invariants. For example: "The sum of all user balances must always equal the contract's total supply," or "No user can withdraw more than they deposited plus earned interest."
Here's a simple property test example using Foundry:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/Vault.sol";
contract VaultInvariantTest is Test {
Vault public vault;
address[] public users;
function setUp() public {
vault = new Vault();
// Create test users
for(uint i = 0; i < 10; i++) {
address user = address(uint160(i + 1));
users.push(user);
vm.deal(user, 100 ether);
}
}
// Invariant: Total deposited should equal contract balance
function invariant_totalEqualsBalance() public {
assertEq(
vault.totalDeposited(),
address(vault).balance,
"Total deposited should match contract balance"
);
}
// Invariant: No user can have negative balance
function invariant_noNegativeBalances() public {
for(uint i = 0; i < users.length; i++) {
assertTrue(
vault.balanceOf(users[i]) >= 0,
"User balance cannot be negative"
);
}
}
// Invariant: Sum of user balances equals total
function invariant_balancesSumToTotal() public {
uint256 sum = 0;
for(uint i = 0; i < users.length; i++) {
sum += vault.balanceOf(users[i]);
}
assertEq(
sum,
vault.totalDeposited(),
"Sum of balances must equal total"
);
}
}
Formal verification takes this further by mathematically proving properties hold for all possible inputs, not just tested ones. Tools like Certora Prover, K Framework, or Move Prover (for Sui) can verify critical properties with mathematical certainty. This is overkill for most contracts but essential for high-value DeFi protocols. Focus formal verification on critical invariants: "The contract can never lose user funds," or "Only the owner can perform admin functions."
Mainnet forking tests are crucial for testing integrations. Use Hardhat or Foundry to fork mainnet state, letting you test against real deployed contracts with actual liquidity and state. This catches integration bugs that unit tests miss. Test your DEX against real Uniswap pools, test your lending protocol against actual Compound markets, test your oracle against real Chainlink feeds. Fork tests should include stress scenarios: what happens during extreme market volatility? What if a integrated protocol gets exploited?
Upgrade simulation is essential for upgradeable contracts. Before deploying an upgrade, test the migration path thoroughly. Deploy the old version, populate it with realistic state, deploy the new version, execute the upgrade transaction in a test environment, verify all state migrated correctly, and confirm all functions work with migrated state. Many upgrade exploits happen because testing focused on new deployments, not actual upgrades from production state.
Gas profiling should be continuous, not an afterthought. Profile gas costs for every function under realistic conditions. Identify the most expensive operations and optimize them. Set gas benchmarks and fail CI if costs exceed thresholds. Gas optimization is user experience—a contract that costs $200 per transaction won't get used regardless of how clever the mechanism is.
Gas Optimization Patterns: Making Contracts Affordable
Gas optimization is about understanding the cost model and making strategic tradeoffs. Storage optimization has the highest impact on Ethereum. Each storage slot costs 20,000 gas to initialize (SSTORE from 0 to non-zero) and 5,000 gas to update (SSTORE from non-zero to non-zero). Reading costs 2,100 gas for cold storage and 100 gas for warm storage. The pattern: pack multiple values into single slots, minimize storage writes, use memory for temporary data, and leverage events for data that doesn't need on-chain queries.
// Bad: Each variable uses a full slot (20,000 gas each to initialize)
contract Inefficient {
uint256 public lastUpdate; // Slot 0
uint256 public count; // Slot 1
address public owner; // Slot 2
bool public active; // Slot 3
}
// Good: Pack into fewer slots (60,000 gas savings)
contract Efficient {
// Slot 0: owner (20 bytes) + active (1 byte) = 21 bytes
address public owner;
bool public active;
// Slot 1: lastUpdate (4 bytes) + count (4 bytes) = 8 bytes
uint32 public lastUpdate;
uint32 public count;
// Saves 2 slots = 40,000 gas on initialization
}
Memory over storage when possible. If you only need data during a transaction, use memory arrays and structs. Memory costs 3 gas per 32 bytes plus quadratic costs for large allocations, but that's far cheaper than storage. Load storage once into memory, operate on memory, then write back once.
Calldata for external functions saves gas over memory. When receiving arrays or structs as parameters in external functions, use calldata location instead of memory. Calldata is read-only but cheaper because the data doesn't need to be copied:
// More expensive: copies array to memory
function processOrders(Order[] memory orders) external {
// ...
}
// Cheaper: reads directly from calldata
function processOrders(Order[] calldata orders) external {
// ...
}
Short-circuit evaluation in conditions. Solidity evaluates conditions left to right and stops on the first false. Put cheaper checks first:
// Bad: Expensive storage read happens first
require(someExpensiveStorageValue > 0 && msg.value > 0, "Invalid");
// Good: Cheap check first, might avoid storage read
require(msg.value > 0 && someExpensiveStorageValue > 0, "Invalid");
Unchecked blocks for safe arithmetic when you've verified overflow is impossible. Solidity 0.8+ adds overflow checks to every operation (gas cost). When you know overflow can't happen, wrap in unchecked block:
function distributeRewards(address[] calldata users, uint256 totalReward) external {
uint256 rewardPerUser = totalReward / users.length;
for(uint256 i = 0; i < users.length;) {
// Distribute reward...
unchecked {
++i; // Can't overflow, saves ~100 gas per iteration
}
}
}
Custom errors over require strings. As of Solidity 0.8.4, custom errors are more gas-efficient than require strings:
// Expensive: String stored in bytecode
require(balance >= amount, "Insufficient balance");
// Cheaper: Error selector only (4 bytes)
error InsufficientBalance(uint256 balance, uint256 amount);
if (balance < amount) revert InsufficientBalance(balance, amount);
On Solana, gas (compute units) optimization focuses on minimizing account accesses, reducing instruction data sizes, and avoiding unnecessary CPIs. Use zero-copy deserialization for large accounts, pack data efficiently in account structures, batch operations when possible, and pre-calculate values off-chain when feasible.
On Sui, gas optimization involves minimizing shared object usage (higher consensus costs), preferring owned objects, using dynamic fields efficiently, and batching transactions. Sui's gas model rewards well-designed object ownership patterns.
Real-World Case Studies: Learning from Production Deployments
The Compound III Architecture demonstrates mature contract design. Instead of one monolithic contract, Compound v3 uses a focused "Comet" contract per market. Each Comet handles one base asset with multiple collateral types. This isolation means bugs in one market don't affect others, upgrades can be tested per-market, and gas costs are optimized for the specific use case. The architecture uses proxy patterns for upgradeability but with strict governance timelocks. The access control uses a Configurator contract that validates parameter changes before applying them, preventing admin errors. Key lesson: isolation and specialization enable safer, more efficient systems.
Uniswap V3's concentrated liquidity required sophisticated smart contract patterns. The core innovation—allowing LPs to provide liquidity in specific price ranges—created complex state management challenges. Uniswap solved this with a two-tier architecture: core contracts handling the critical swap logic and periphery contracts providing user-friendly interfaces. Core contracts are immutable and minimal, reducing attack surface. The tick-based price tracking uses efficient bitmap operations to minimize storage writes. Position NFTs represent LP positions, enabling composability. Key lesson: separate critical immutable logic from user-facing interfaces.
Lido's staked ETH (stETH) architecture shows how to handle complex state across millions of users efficiently. Instead of tracking individual user balances in storage (prohibitively expensive), Lido uses a shares-based system. Users own shares, and the price of shares increases as staking rewards accrue. This means rewards distribution requires zero storage writes—just updating the total pooled ETH value. The rebase mechanism automatically updates everyone's balance. The architecture includes withdrawal queues, oracle-based reward reporting, and sophisticated governance. Key lesson: clever accounting mechanisms can transform O(n) operations into O(1).
Solana's SPL Token program demonstrates how to design reusable program infrastructure. Instead of every project deploying token code, SPL Token provides a single deployed program that anyone can use by creating accounts. This radically reduces blockchain state bloat and ensures battle-tested token logic. The Associated Token Account pattern emerged to standardize how users manage tokens. Extensions allow customizing token behavior (transfer hooks, confidential transfers) while maintaining the core program. Key lesson: standardized infrastructure with extension points enables ecosystem growth.
Sui's DeepBook orderbook implementation shows object-centric architecture advantages. Each order is an owned object, allowing parallel processing of non-conflicting orders without consensus. Only the shared pool object requires consensus for actual trade settlement. The capability-based design allows sophisticated order types and delegation without complex access control logic. Orders can be composed with other actions atomically. Key lesson: proper object modeling eliminates consensus bottlenecks while maintaining composability.
The DAO hack (2016) remains the definitive case study in what not to do. The vulnerability was a reentrancy attack where the attacker called back into the contract before the balance update completed. The root cause: not following Checks-Effects-Interactions. The broader lesson: the contract mixed multiple concerns (governance, treasury, investment), had complex state management, and rushed to production without adequate security review. The hack led to Ethereum's fork and established security practices now considered basic. Key lesson: fundamentals matter more than features, and security can't be an afterthought.
Tooling and Development Workflow: Setting Up for Success
Your development environment directly impacts code quality and security. For Ethereum, the modern stack uses Foundry for testing (blazing fast, property testing built-in), Hardhat for deployment scripts and mainnet forking, and Slither for static analysis. Supplement with Echidna for property-based fuzzing and Mythril for symbolic execution. Set up continuous integration to run all tests and static analysis on every commit. Use VS Code with the Solidity extension for inline warnings.
The workflow: write contracts in Solidity 0.8.x or later, write comprehensive unit tests in Solidity using Foundry, add property-based tests defining invariants, run Slither on every build to catch common issues, use Hardhat to fork mainnet and test integrations, profile gas costs and track regressions, and run Echidna overnight to find edge cases. Before audit: freeze features, achieve 90%+ coverage, fix all high/medium Slither findings, and document all known issues.
For Solana, use the Anchor framework—it codifies security best practices into the framework itself, provides account validation macros, generates client libraries automatically, and includes testing utilities. Supplement with Solana Test Validator for local testing and Amman for fixture management. The Anchor workflow: define programs in Rust with Anchor framework, use account constraints to validate inputs, write TypeScript tests using Anchor's testing library, test both on local validator and devnet, use compute unit profiling to optimize, and run Soteria analyzer for security issues.
For Sui, use Move Prover for formal verification, which is built into the Move language tooling. The Sui CLI provides testing and publishing tools. Write tests in Move using test annotations, use the prover to verify critical properties, test on local network then devnet, profile gas usage with transaction analysis, and use Sui Explorer to inspect on-chain state. Move's type system catches many issues at compile time that would be runtime errors on other platforms.
Version control hygiene matters. Use semantic versioning, tag releases, maintain a changelog, never force-push to main, require PR reviews for all changes, and run CI on all PRs. Document deployment procedures. Keep separate repositories for contracts and front-ends—they have different security requirements and release cadences.
Deployment procedures should be scripted and rehearsed. Deploy to testnet first, verify contracts on block explorers, test all functions on testnet with realistic scenarios, document deployment addresses, set up monitoring immediately after mainnet deployment, and prepare for emergency responses. Use multi-sig wallets for ownership from day one.
Monitoring and alerting is essential post-deployment. Track contract balance, unusual gas usage, large withdrawals, failed transactions, and unusual transaction patterns. Set up alerts that page you immediately for anomalies. Use services like Tenderly for real-time monitoring and simulation. Monitor your own contracts, but also track protocols you integrate with—their issues become your issues.
Conclusion: Architecture Decisions That Last
Smart contract architecture isn't about following trends—it's about understanding fundamental tradeoffs and making conscious decisions that serve your specific use case. The patterns we've covered—from Ethereum's Checks-Effects-Interactions to Solana's PDAs to Sui's capability objects—aren't arbitrary conventions. They're solutions to real problems discovered through billions of dollars in exploits and years of production experience. Your responsibility as a developer is to understand why these patterns exist, when to apply them, and when different patterns might be appropriate.
The blockchain you choose fundamentally shapes your architecture. Ethereum's mature ecosystem and composability enable complex DeFi protocols but demand careful gas optimization and reentrancy protection. Solana's parallel execution and low costs enable high-frequency applications but require rethinking account structures and state management. Sui's object model and Move language eliminate certain vulnerability classes while enabling new design patterns around ownership and capabilities. There's no universally best choice—only tradeoffs that align with your requirements.
As you build, remember that smart contracts are financial infrastructure operating in adversarial conditions with no room for error. The patterns in this guide aren't suggestions—they're hard-won lessons from the field. Follow Checks-Effects-Interactions religiously. Implement comprehensive access control. Validate all inputs. Design for failure. Test adversarially. Get audited. Monitor production. These aren't optional extras to add "if there's time"—they're the minimum bar for production-ready contracts. Every shortcut you take is a vulnerability waiting to be exploited. Every best practice you skip is a user waiting to lose funds.
The field evolves rapidly. New patterns emerge as developers push boundaries and discover edge cases. Stay current with security disclosures, read post-mortems from exploits, study codebases of successful protocols, participate in audit contests, and engage with the security community. Your education never stops because attackers never stop innovating. The smart contract you deploy today needs to withstand attacks that haven't been invented yet. Design with that reality in mind, build defense in depth, and never assume you've thought of everything. The most successful contracts are those built by teams humble enough to know they might be wrong and careful enough to prepare for it.