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
Installation
npx clawhub@latest install web3-solidityView 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:
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
| Feature | Hardhat | Foundry |
| Language | JavaScript/TypeScript | Solidity (tests in Solidity) |
| Test speed | Slower | 10-100× faster |
| Fuzz testing | Plugin required | Built-in |
| Deployment | Hardhat Ignition | forge script |
| Chain interaction | ethers.js / viem | cast |
| Local chain | Hardhat Network | anvil |
| Plugin ecosystem | Large | Growing |
| TypeScript support | Native | Requires codegen |
| Learning curve | Easier (JS devs) | Steeper (Solidity-first) |
| Best for | JS teams, complex deployment scripts | Solidity experts, security 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 +
nonReentrantmodifier - ○Access control: All privileged functions have role checks
- ○Integer math: Solidity 0.8+ or SafeMath
- ○ETH transfers:
.call{value}with success check (nottransfer) - ○Front-running: Commit-reveal or slippage protection
- ○Randomness: Chainlink VRF (not
block.timestamporblockhash) - ○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