Smart Contract Security Patterns

Critical security patterns and best practices for secure smart contract development in Rust.

Advanced⏱️ 70 min📚 Prerequisites: 2

Smart Contract Security Patterns

Security is paramount in smart contracts. Bugs can lead to loss of funds. Rust's type system helps, but you must follow security best practices.

Common Vulnerabilities

1. Reentrancy Attacks

Problem: External calls can re-enter the contract before state updates.

RUST
// VULNERABLE CODE
struct Bank {
    balances: HashMap<String, u64>,
}

impl Bank {
    fn withdraw(&mut self, user: &str, amount: u64) -> Result<(), String> {
        let balance = self.balances.get(user).copied().unwrap_or(0);
        if balance < amount {
            return Err(String::from("Insufficient balance"));
        }
        
        // VULNERABLE: External call before state update
        external_transfer(user, amount)?;
        
        // State update happens after external call
        *self.balances.get_mut(user).unwrap() -= amount;
        Ok(())
    }
}

Solution: Checks-Effects-Interactions Pattern

RUST
// SECURE CODE
impl Bank {
    fn withdraw(&mut self, user: &str, amount: u64) -> Result<(), String> {
        // CHECK: Validate inputs
        let balance = self.balances.get(user).copied().unwrap_or(0);
        if balance < amount {
            return Err(String::from("Insufficient balance"));
        }
        
        // EFFECTS: Update state FIRST
        *self.balances.get_mut(user).unwrap() -= amount;
        
        // INTERACTIONS: External call LAST
        external_transfer(user, amount)?;
        
        Ok(())
    }
}

2. Integer Overflow/Underflow

Problem: Arithmetic operations can overflow.

RUST
// VULNERABLE
fn add_balance(balance: u64, amount: u64) -> u64 {
    balance + amount  // Can overflow!
}

Solution: Use Checked Arithmetic

RUST
// SECURE
fn add_balance(balance: u64, amount: u64) -> Result<u64, String> {
    balance.checked_add(amount)
        .ok_or_else(|| String::from("Overflow"))
}

fn subtract_balance(balance: u64, amount: u64) -> Result<u64, String> {
    balance.checked_sub(amount)
        .ok_or_else(|| String::from("Underflow"))
}

3. Access Control

Problem: Missing permission checks.

RUST
// VULNERABLE
struct Contract {
    owner: String,
    funds: u64,
}

impl Contract {
    fn withdraw(&mut self, amount: u64) -> Result<(), String> {
        // Missing owner check!
        self.funds -= amount;
        Ok(())
    }
}

Solution: Always Check Permissions

RUST
// SECURE
impl Contract {
    fn withdraw(&mut self, caller: &str, amount: u64) -> Result<(), String> {
        // Check access
        if caller != self.owner {
            return Err(String::from("Unauthorized"));
        }
        
        // Validate amount
        if amount > self.funds {
            return Err(String::from("Insufficient funds"));
        }
        
        self.funds -= amount;
        Ok(())
    }
}

4. Input Validation

Problem: Unvalidated inputs can cause issues.

RUST
// VULNERABLE
fn transfer(&mut self, to: String, amount: u64) {
    // No validation!
    self.balances.entry(to).or_insert(0) += amount;
}

Solution: Validate All Inputs

RUST
// SECURE
fn transfer(
    &mut self,
    from: &str,
    to: &str,
    amount: u64,
) -> Result<(), String> {
    // Validate addresses
    if from.is_empty() || to.is_empty() {
        return Err(String::from("Invalid address"));
    }
    
    if from == to {
        return Err(String::from("Cannot transfer to self"));
    }
    
    // Validate amount
    if amount == 0 {
        return Err(String::from("Amount must be > 0"));
    }
    
    // Validate balance
    let balance = self.balances.get(from).copied().unwrap_or(0);
    if balance < amount {
        return Err(String::from("Insufficient balance"));
    }
    
    // Execute transfer
    *self.balances.entry(from.to_string()).or_insert(0) -= amount;
    *self.balances.entry(to.to_string()).or_insert(0) += amount;
    
    Ok(())
}

Security Patterns

1. Reentrancy Guard

RUST
struct ReentrancyGuard {
    locked: bool,
}

impl ReentrancyGuard {
    fn new() -> Self {
        ReentrancyGuard { locked: false }
    }
    
    fn lock(&mut self) -> Result<(), String> {
        if self.locked {
            return Err(String::from("Reentrancy detected"));
        }
        self.locked = true;
        Ok(())
    }
    
    fn unlock(&mut self) {
        self.locked = false;
    }
}

// Usage
fn withdraw(&mut self, guard: &mut ReentrancyGuard, amount: u64) -> Result<(), String> {
    guard.lock()?;
    
    // Safe operations
    self.balance -= amount;
    
    guard.unlock();
    Ok(())
}

2. Access Control Modifier

RUST
trait AccessControl {
    fn require_owner(&self, caller: &str) -> Result<(), String>;
    fn require_admin(&self, caller: &str) -> Result<(), String>;
}

struct Contract {
    owner: String,
    admins: HashSet<String>,
}

impl AccessControl for Contract {
    fn require_owner(&self, caller: &str) -> Result<(), String> {
        if caller != self.owner {
            return Err(String::from("Not owner"));
        }
        Ok(())
    }
    
    fn require_admin(&self, caller: &str) -> Result<(), String> {
        if !self.admins.contains(caller) && caller != self.owner {
            return Err(String::from("Not admin"));
        }
        Ok(())
    }
}

3. Safe Math Library

RUST
pub mod safe_math {
    pub fn checked_add(a: u64, b: u64) -> Result<u64, String> {
        a.checked_add(b).ok_or_else(|| String::from("Addition overflow"))
    }
    
    pub fn checked_sub(a: u64, b: u64) -> Result<u64, String> {
        a.checked_sub(b).ok_or_else(|| String::from("Subtraction underflow"))
    }
    
    pub fn checked_mul(a: u64, b: u64) -> Result<u64, String> {
        a.checked_mul(b).ok_or_else(|| String::from("Multiplication overflow"))
    }
    
    pub fn checked_div(a: u64, b: u64) -> Result<u64, String> {
        if b == 0 {
            return Err(String::from("Division by zero"));
        }
        Ok(a / b)
    }
}

Security Checklist

  • All external calls use checks-effects-interactions
  • All arithmetic uses checked operations
  • All functions check permissions
  • All inputs are validated
  • Reentrancy guards where needed
  • Events emitted for important actions
  • Error messages are clear
  • Gas optimization considered
  • Code reviewed by security experts
  • Comprehensive test coverage

Testing Security

RUST
#[cfg(test)]
mod security_tests {
    use super::*;
    
    #[test]
    fn test_reentrancy_protection() {
        // Test that reentrancy is prevented
    }
    
    #[test]
    fn test_overflow_protection() {
        // Test overflow handling
        let max = u64::MAX;
        assert!(safe_math::checked_add(max, 1).is_err());
    }
    
    #[test]
    fn test_access_control() {
        // Test unauthorized access fails
        let contract = Contract::new(String::from("owner"));
        assert!(contract.withdraw("hacker", 100).is_err());
    }
}

Best Practices

  1. Minimize External Calls: Reduce attack surface
  2. Use Libraries: Don't reinvent security
  3. Keep It Simple: Complex code = more bugs
  4. Test Extensively: Test edge cases
  5. Code Review: Get security review
  6. Audit: Professional security audit
  7. Document: Document security assumptions
  8. Monitor: Monitor for unusual activity

Code Examples

Reentrancy Protection

Implementing reentrancy guard

RUST
struct ReentrancyGuard {
    locked: bool,
}

impl ReentrancyGuard {
    fn new() -> Self {
        ReentrancyGuard { locked: false }
    }
    
    fn lock(&mut self) -> Result<(), String> {
        if self.locked {
            return Err(String::from("Reentrancy detected"));
        }
        self.locked = true;
        Ok(())
    }
    
    fn unlock(&mut self) {
        self.locked = false;
    }
}

struct Bank {
    balances: std::collections::HashMap<String, u64>,
    guard: ReentrancyGuard,
}

impl Bank {
    fn withdraw(&mut self, user: &str, amount: u64) -> Result<(), String> {
        // Lock to prevent reentrancy
        self.guard.lock()?;
        
        // Checks
        let balance = self.balances.get(user).copied().unwrap_or(0);
        if balance < amount {
            self.guard.unlock();
            return Err(String::from("Insufficient balance"));
        }
        
        // Effects: Update state FIRST
        *self.balances.entry(user.to_string()).or_insert(0) -= amount;
        
        // Unlock before external call
        self.guard.unlock();
        
        // Interactions: External call LAST
        // external_transfer(user, amount)?;
        
        Ok(())
    }
}

fn main() {
    let mut bank = Bank {
        balances: std::collections::HashMap::new(),
        guard: ReentrancyGuard::new(),
    };
    
    bank.balances.insert(String::from("alice"), 1000);
    
    match bank.withdraw("alice", 500) {
        Ok(_) => println!("Withdrawal successful"),
        Err(e) => println!("Error: {}", e),
    }
}

Explanation:

Reentrancy guards prevent recursive calls that could exploit state updates. The guard locks during critical sections and unlocks after state is updated.

Safe Math Operations

Using checked arithmetic to prevent overflows

RUST
fn safe_add(a: u64, b: u64) -> Result<u64, String> {
    a.checked_add(b).ok_or_else(|| String::from("Addition overflow"))
}

fn safe_sub(a: u64, b: u64) -> Result<u64, String> {
    a.checked_sub(b).ok_or_else(|| String::from("Subtraction underflow"))
}

fn safe_mul(a: u64, b: u64) -> Result<u64, String> {
    a.checked_mul(b).ok_or_else(|| String::from("Multiplication overflow"))
}

fn safe_div(a: u64, b: u64) -> Result<u64, String> {
    if b == 0 {
        return Err(String::from("Division by zero"));
    }
    Ok(a / b)
}

struct TokenContract {
    balances: std::collections::HashMap<String, u64>,
}

impl TokenContract {
    fn transfer(
        &mut self,
        from: &str,
        to: &str,
        amount: u64,
    ) -> Result<(), String> {
        let from_balance = self.balances.get(from).copied().unwrap_or(0);
        
        // Safe subtraction
        let new_from_balance = safe_sub(from_balance, amount)?;
        
        // Safe addition
        let to_balance = self.balances.get(to).copied().unwrap_or(0);
        let new_to_balance = safe_add(to_balance, amount)?;
        
        self.balances.insert(from.to_string(), new_from_balance);
        self.balances.insert(to.to_string(), new_to_balance);
        
        Ok(())
    }
}

fn main() {
    let mut contract = TokenContract {
        balances: std::collections::HashMap::new(),
    };
    
    contract.balances.insert(String::from("alice"), 1000);
    
    match contract.transfer("alice", "bob", 500) {
        Ok(_) => println!("Transfer successful"),
        Err(e) => println!("Error: {}", e),
    }
}

Explanation:

Checked arithmetic prevents integer overflow and underflow. Always use checked operations for financial calculations in smart contracts.

Exercises

Implement Access Control

Add access control to a contract function!

Medium

Starter Code:

RUST
struct Contract {
    owner: String,
    value: u64,
}

impl Contract {
    fn set_value(&mut self, caller: &str, new_value: u64) -> Result<(), String> {
        // Add owner check
        self.value = new_value;
        Ok(())
    }
}

fn main() {
    let mut contract = Contract {
        owner: String::from("owner"),
        value: 0,
    };
    
    contract.set_value("owner", 100).unwrap();
    println!("Value: {}", contract.value);
}