Skip to main content
Coding Agents & IDEsDocumented

web3-solidity

Expert Solidity and smart contract development covering security patterns, Foundry workflow, vulnerability prevention, gas optimization, ERC standards, proxy upgradability patterns, event design, and a comparison of Hardhat vs Foundry tooling. Trigge

Share:

Installation

npx clawhub@latest install web3-solidity

View the full skill documentation and source below.

Documentation

Web3 & Solidity Expert

Smart contract development is unique: bugs are permanent, public, and financially catastrophic. A reentrancy vulnerability in 2016 resulted in the $60M DAO hack. In 2022, a bridge contract bug enabled a $320M Wormhole exploit. The EVM (Ethereum Virtual Machine) is an adversarial execution environment — your code will be probed by bots within seconds of deployment. Every line of Solidity you write is a potential attack vector.

The professional approach: write minimal code, follow established patterns religiously, test exhaustively (including fuzz tests), and get a security audit before any significant value-holding contract goes to mainnet.

Core Mental Model

The EVM operates on three key invariants your code must never violate:

  • Reentrancy: External calls can re-enter your contract before state is updated

  • Integer arithmetic: Overflow/underflow was a class of bug before Solidity 0.8 (now checked by default)

  • Access control: Every sensitive function must validate who is calling it
  • The Checks-Effects-Interactions pattern prevents most reentrancy attacks. The checks validate preconditions. The effects update state. Interactions (external calls) happen LAST. This ordering means that if an external call re-enters, your state is already updated correctly.

    function withdraw(uint256 amount) external {
      // CHECKS: validate preconditions
      require(balances[msg.sender] >= amount, "Insufficient balance");
      
      // EFFECTS: update state BEFORE external calls
      balances[msg.sender] -= amount;
      totalDeposited -= amount;
      
      // INTERACTIONS: external calls last
      (bool success, ) = msg.sender.call{value: amount}("");
      require(success, "Transfer failed");
    }

    Foundry Workflow

    Foundry (forge, cast, anvil) is the professional Solidity development toolkit. It's faster, more powerful, and closer to production than Hardhat for most workflows.

    Project Setup

    # Install
    curl -L https://foundry.paradigm.xyz | bash && foundryup
    
    # Initialize project
    forge init my-project
    cd my-project
    
    # Project structure
    src/          → Solidity contracts
    test/         → Foundry tests (also Solidity)
    script/       → Deployment scripts
    lib/          → Dependencies (git submodules)
    foundry.toml  → Config
    
    # Install dependencies (OpenZeppelin, etc.)
    forge install OpenZeppelin/openzeppelin-contracts
    forge install transmissions11/solmate
    
    # Build
    forge build
    
    # Test (verbose)
    forge test -vvvv

    Writing Tests with Foundry

    // test/Token.t.sol
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.20;
    
    import "forge-std/Test.sol";
    import "../src/Token.sol";
    
    contract TokenTest is Test {
        Token public token;
        address alice = makeAddr("alice");
        address bob   = makeAddr("bob");
        
        function setUp() public {
            token = new Token("MyToken", "MTK", 18);
            
            // Fund alice with 100 tokens
            deal(address(token), alice, 100 ether);
        }
        
        function test_Transfer() public {
            vm.prank(alice);  // Next call comes from alice
            token.transfer(bob, 50 ether);
            
            assertEq(token.balanceOf(bob), 50 ether);
            assertEq(token.balanceOf(alice), 50 ether);
        }
        
        function test_Transfer_RevertIfInsufficientBalance() public {
            vm.prank(alice);
            vm.expectRevert("ERC20: transfer amount exceeds balance");
            token.transfer(bob, 200 ether);  // alice only has 100
        }
        
        // FUZZ TEST: Foundry generates random inputs automatically
        function testFuzz_Transfer(uint256 amount) public {
            // Bound to valid range
            amount = bound(amount, 0, token.balanceOf(alice));
            
            vm.prank(alice);
            token.transfer(bob, amount);
            
            assertEq(token.balanceOf(bob), amount);
            assertEq(token.balanceOf(alice), 100 ether - amount);
        }
        
        // INVARIANT TEST: property that must always hold
        function invariant_TotalSupplyConstant() public {
            // Total supply should never change unless mint/burn
            assertEq(token.totalSupply(), INITIAL_SUPPLY);
        }
    }
    # Run specific test
    forge test --match-test test_Transfer
    
    # Fuzz runs (default 256, increase for thoroughness)
    forge test --match-test testFuzz --fuzz-runs 10000
    
    # Invariant testing
    forge test --match-test invariant
    
    # Gas report
    forge test --gas-report
    
    # Coverage
    forge coverage --report lcov

    forge script for Deployment

    // script/Deploy.s.sol
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.20;
    
    import "forge-std/Script.sol";
    import "../src/Token.sol";
    
    contract DeployScript is Script {
        function run() external returns (Token token) {
            uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
            
            vm.startBroadcast(deployerPrivateKey);
            
            token = new Token("MyToken", "MTK", 18);
            token.mint(msg.sender, 1_000_000 ether);
            
            vm.stopBroadcast();
            
            console.log("Token deployed at:", address(token));
        }
    }
    # Simulate (no broadcast)
    forge script script/Deploy.s.sol --rpc-url $RPC_URL
    
    # Deploy to testnet
    forge script script/Deploy.s.sol \
      --rpc-url $SEPOLIA_RPC_URL \
      --broadcast \
      --verify \
      --etherscan-api-key $ETHERSCAN_KEY \
      -vvvv
    
    # cast for chain interaction
    cast call 0xContractAddr "balanceOf(address)(uint256)" 0xUserAddr --rpc-url $RPC
    cast send 0xContractAddr "transfer(address,uint256)" 0xTo 1000000 --private-key $KEY
    cast block latest --rpc-url $RPC

    Common Vulnerabilities and Fixes

    1. Reentrancy

    // VULNERABLE
    contract VulnerableVault {
        mapping(address => uint256) balances;
        
        function withdraw() external {
            uint256 amount = balances[msg.sender];
            // Attacker's receive() calls withdraw() again before this line:
            (bool success, ) = msg.sender.call{value: amount}("");
            require(success);
            balances[msg.sender] = 0;  // Too late — already re-entered
        }
    }
    
    // FIXED: Checks-Effects-Interactions + ReentrancyGuard
    import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
    
    contract SecureVault is ReentrancyGuard {
        mapping(address => uint256) balances;
        
        function withdraw() external nonReentrant {
            uint256 amount = balances[msg.sender];
            require(amount > 0, "Nothing to withdraw");
            
            balances[msg.sender] = 0;  // Effect BEFORE external call
            
            (bool success, ) = msg.sender.call{value: amount}("");
            require(success, "Transfer failed");
        }
    }

    2. tx.origin vs msg.sender

    // VULNERABLE: tx.origin is the original transaction initiator
    // A malicious contract can trick you
    function transfer(address to, uint256 amount) external {
        require(tx.origin == owner, "Not owner");  // ❌ phishing attack possible
        _transfer(owner, to, amount);
    }
    
    // FIXED: msg.sender is the immediate caller
    function transfer(address to, uint256 amount) external {
        require(msg.sender == owner, "Not owner");  // ✅
        _transfer(owner, to, amount);
    }

    3. Front-Running

    // Front-running: MEV bots see pending transactions and reorder them
    // Common in: DEX trades, NFT mints with variable pricing, blind auctions
    
    // Mitigation: Commit-Reveal scheme for auctions
    contract SecureAuction {
        mapping(address => bytes32) public commitments;
        
        // Phase 1: Commit (bid is hidden)
        function commit(bytes32 commitment) external {
            commitments[msg.sender] = commitment;
        }
        
        // Phase 2: Reveal (after commit period ends)
        function reveal(uint256 bid, bytes32 secret) external {
            bytes32 expected = keccak256(abi.encodePacked(bid, secret, msg.sender));
            require(commitments[msg.sender] == expected, "Invalid reveal");
            // Process bid...
        }
    }
    
    // Slippage protection for DEX interactions
    function swap(uint256 amountIn, uint256 minAmountOut) external {
        uint256 amountOut = calculateOutput(amountIn);
        require(amountOut >= minAmountOut, "Slippage too high");  // Protection
        _executeSwap(amountIn, amountOut);
    }

    4. Integer Overflow/Underflow

    // Solidity 0.8+ has built-in overflow checking (reverts on overflow)
    // For older code or when you need unchecked math for gas savings:
    
    function dangerousAdd(uint256 a, uint256 b) external pure returns (uint256) {
        // In 0.8+, this reverts on overflow automatically
        return a + b;
    }
    
    // unchecked block for gas optimization (only when you've proven safety)
    function safeIncrement(uint256 i, uint256 max) external pure returns (uint256) {
        unchecked {
            if (i < max) return i + 1;  // Safe: we know i < max, so i+1 <= max <= type(uint256).max
            return 0;
        }
    }

    Gas Optimization

    Storage vs Memory vs Calldata

    // Storage: Persistent, most expensive (20,000 gas for new slot, 5,000 for update)
    // Memory:  Temporary, cheap (~3 gas per word)
    // Calldata: Read-only function args, cheapest for external functions
    
    // Minimize storage reads: cache in memory
    function process(uint256 id) external {
        // BAD: reads storage twice
        require(items[id].owner == msg.sender, "Not owner");
        emit Transfer(items[id].owner, newOwner);  // Second storage read
        
        // GOOD: cache in memory
        Item memory item = items[id];  // One storage read
        require(item.owner == msg.sender, "Not owner");
        emit Transfer(item.owner, newOwner);
    }
    
    // Use calldata for external function array/struct params (not memory)
    function processAll(uint256[] calldata ids) external {  // ✅ calldata
        // vs uint256[] memory ids — calldata is cheaper for external calls
    }

    Variable Packing

    // Solidity stores variables in 32-byte slots
    // Packing multiple small variables in one slot saves storage costs
    
    // BAD: 3 storage slots
    contract Unpacked {
        uint256 a;   // slot 0 (32 bytes)
        uint8 b;     // slot 1 (wasteful — 31 bytes empty)
        uint256 c;   // slot 2 (32 bytes)
    }
    
    // GOOD: 2 storage slots
    contract Packed {
        uint256 a;   // slot 0 (32 bytes)
        uint8 b;     // slot 1 (starts new slot)
        uint8 c;     // slot 1 (packed with b — same slot!)
        uint240 d;   // slot 1 (8+8+240 = 256 bits = 32 bytes, perfect fit)
    }
    
    // Structs: order fields by size (largest first) for packing
    struct Optimized {
        uint256 amount;      // 32 bytes → slot 0
        address owner;       // 20 bytes → slot 1
        uint96 timestamp;    // 12 bytes → slot 1 (packed with owner: 20+12=32 bytes)
        bool active;         // 1 byte  → slot 2
        uint8 status;        // 1 byte  → slot 2 (packed with active)
    }

    Avoiding Unbounded Loops

    // DANGEROUS: gas cost unbounded, can exceed block gas limit
    function payAllInvestors() external {
        for (uint256 i = 0; i < investors.length; i++) {  // ❌ unbounded
            payable(investors[i]).transfer(amounts[i]);
        }
    }
    
    // SAFE: Pull-over-push pattern
    mapping(address => uint256) public pendingWithdrawals;
    
    function distributeDividend() external {
        for (uint256 i = 0; i < investors.length; i++) {
            pendingWithdrawals[investors[i]] += calculateDividend(investors[i]);
        }
        // Note: this is still risky if investors.length is very large
    }
    
    function withdraw() external {
        uint256 amount = pendingWithdrawals[msg.sender];
        pendingWithdrawals[msg.sender] = 0;
        payable(msg.sender).transfer(amount);
    }

    ERC Standards

    ERC-20 (Fungible Token)

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.20;
    
    import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
    import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
    import "@openzeppelin/contracts/access/Ownable.sol";
    
    contract MyToken is ERC20, ERC20Permit, Ownable {
        constructor(address initialOwner)
            ERC20("My Token", "MTK")
            ERC20Permit("My Token")
            Ownable(initialOwner)
        {
            _mint(initialOwner, 1_000_000 * 10 ** decimals());
        }
        
        function mint(address to, uint256 amount) external onlyOwner {
            _mint(to, amount);
        }
    }

    ERC-721 (NFT)

    import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
    import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
    
    contract MyNFT is ERC721URIStorage, Ownable {
        uint256 private _nextTokenId;
        
        constructor(address initialOwner) 
            ERC721("MyNFT", "MNFT") 
            Ownable(initialOwner) {}
        
        function mint(address to, string memory tokenURI) external onlyOwner returns (uint256) {
            uint256 tokenId = _nextTokenId++;
            _safeMint(to, tokenId);
            _setTokenURI(tokenId, tokenURI);
            return tokenId;
        }
    }

    Proxy Patterns for Upgradability

    // UUPS Proxy (recommended by OpenZeppelin)
    // Logic contract includes upgrade logic (cheaper than transparent proxy)
    
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.20;
    
    import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
    import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
    import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
    
    contract MyContractV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
        uint256 public value;
        
        /// @custom:oz-upgrades-unsafe-allow constructor
        constructor() { _disableInitializers(); }
        
        // Called instead of constructor (constructors don't run in proxy pattern)
        function initialize(address initialOwner) public initializer {
            __Ownable_init(initialOwner);
            __UUPSUpgradeable_init();
            value = 42;
        }
        
        // Only owner can upgrade (must implement for UUPS)
        function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
    }
    
    // V2 adds new functionality
    contract MyContractV2 is MyContractV1 {
        uint256 public newValue;  // New storage variable (always append, never change order)
        
        function setNewValue(uint256 v) external onlyOwner {
            newValue = v;
        }
    }
    # Deploy proxy with OpenZeppelin Upgrades plugin
    # hardhat: npx hardhat run scripts/deploy_proxy.js
    # foundry: use openzeppelin-foundry-upgrades plugin
    
    forge script script/DeployProxy.s.sol --broadcast
    # Upgrade: forge script script/UpgradeProxy.s.sol --broadcast

    Event Design for Off-Chain Indexing

    // Events are the cheapest way to store data on-chain (indexed = searchable)
    // The Graph protocol indexes events to build queryable GraphQL APIs
    
    contract TokenVault {
        // indexed parameters = filterable in The Graph / eth_getLogs
        event Deposited(
            address indexed user,
            address indexed token,
            uint256 amount,
            uint256 timestamp
        );
        
        event Withdrawn(
            address indexed user,
            address indexed token,
            uint256 amount,
            uint256 indexed withdrawalId  // indexed for efficient lookups
        );
        
        // Design principle: emit events for every state change
        // Include enough data to reconstruct state without reading storage
        event OwnershipTransferred(
            address indexed previousOwner,
            address indexed newOwner
        );
    }
    
    // The Graph subgraph mapping (AssemblyScript)
    // schema.graphql:
    // type Deposit @entity {
    //   id: ID!
    //   user: Bytes!
    //   token: Bytes!
    //   amount: BigInt!
    //   timestamp: BigInt!
    // }
    
    // mapping.ts:
    // export function handleDeposited(event: DepositedEvent): void {
    //   let deposit = new Deposit(event.transaction.hash.toHex())
    //   deposit.user = event.params.user
    //   deposit.token = event.params.token
    //   deposit.amount = event.params.amount
    //   deposit.save()
    // }

    Hardhat vs Foundry

    FeatureHardhatFoundry
    LanguageJavaScript/TypeScriptSolidity (tests in Solidity)
    Test speedSlower10-100× faster
    Fuzz testingPlugin requiredBuilt-in
    DeploymentHardhat Ignitionforge script
    Chain interactionethers.js / viemcast
    Local chainHardhat Networkanvil
    Plugin ecosystemLargeGrowing
    TypeScript supportNativeRequires codegen
    Learning curveEasier (JS devs)Steeper (Solidity-first)
    Best forJS teams, complex deployment scriptsSolidity experts, security testing
    Recommendation: Use Foundry as primary. Add Hardhat if you need TypeScript deployment scripts or JS-based testing.

    Anti-Patterns

    External call before state update — Classic reentrancy. Always CEI (Checks-Effects-Interactions).

    Storing large data on-chain — Gas cost for storage is ~$0.01-$1 per 32 bytes at current gas prices. Store on IPFS/Arweave; store the hash on-chain.

    Using transfer() or send() for ETH transfers — They forward only 2300 gas stipend, which is insufficient for smart contract recipients. Use .call{value: amount}("") with success check.

    block.timestamp for randomness — Miners can manipulate timestamps by ~15 seconds. Use Chainlink VRF for randomness.

    Uninitialized proxies — Forgetting to call initializer on a proxy leaves it in an inconsistent state that attackers can exploit.

    No access control on privileged functions — Any function that moves funds, upgrades contracts, or changes critical state must have onlyOwner or a role check.

    Mutable storage variables in upgradeable contracts — Changing storage layout in V2 (reordering, removing) corrupts existing storage. Always append new variables.

    Quick Reference

    Security Checklist

    • Reentrancy: CEI pattern + nonReentrant modifier
    • Access control: All privileged functions have role checks
    • Integer math: Solidity 0.8+ or SafeMath
    • ETH transfers: .call{value} with success check (not transfer)
    • Front-running: Commit-reveal or slippage protection
    • Randomness: Chainlink VRF (not block.timestamp or blockhash)
    • External contracts: Don't trust arbitrary addresses
    • Upgradeability: Storage layout documented and enforced

    Foundry Cheat Codes

    vm.prank(address)           // Spoofs msg.sender for next call only
    vm.startPrank(address)      // Spoofs msg.sender for all calls until stopPrank()
    vm.deal(address, amount)    // Sets ETH balance
    deal(token, address, amount) // Sets ERC20 balance
    vm.expectRevert("message")  // Expect the next call to revert
    vm.expectEmit(...)          // Expect specific event emission
    vm.warp(timestamp)          // Set block.timestamp
    vm.roll(blockNumber)        // Set block.number
    vm.snapshot()               // Save state (returns id)
    vm.revertTo(id)             // Revert to snapshot