$3d
Ce skill couvre les patterns canoniques pour écrire des smart contracts Solidity production-grade. Solidity 0.8.x est la baseline en 2026 — les versions plus anciennes ont des pièges (pas d'overflow check natif).
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge init my-project
cd my-project
forge install OpenZeppelin/openzeppelin-contracts
foundry.toml :
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc_version = "0.8.26"
optimizer = true
optimizer_runs = 200
via_ir = true
fuzz_runs = 1000
[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"
sepolia = "${SEPOLIA_RPC_URL}"
forge build
forge test -vvv
forge script script/Deploy.s.sol --rpc-url sepolia --broadcast --verify
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract MyToken is ERC20, Ownable2Step, ReentrancyGuard {
// === Custom errors (gas-cheap vs require strings) ===
error ZeroAddress();
error InsufficientBalance(uint256 requested, uint256 available);
error TransferFailed();
// === Events ===
event TokensWithdrawn(address indexed user, uint256 amount);
// === Immutable state (gas-cheap, set in constructor) ===
uint256 public immutable maxSupply;
// === Storage ===
mapping(address account => uint256 lastClaim) public lastClaims;
constructor(uint256 _maxSupply, address _owner)
ERC20("MyToken", "MTK")
Ownable(_owner)
{
if (_owner == address(0)) revert ZeroAddress();
maxSupply = _maxSupply;
}
function mint(address to, uint256 amount) external onlyOwner {
if (totalSupply() + amount > maxSupply) {
revert InsufficientBalance({requested: amount, available: maxSupply - totalSupply()});
}
_mint(to, amount);
}
// === Withdrawal pattern ===
function withdraw() external nonReentrant {
uint256 amount = pendingWithdrawals[msg.sender];
if (amount == 0) revert InsufficientBalance(0, 0);
// CEI: Checks-Effects-Interactions
pendingWithdrawals[msg.sender] = 0;
emit TokensWithdrawn(msg.sender, amount);
(bool success, ) = msg.sender.call{value: amount}("");
if (!success) revert TransferFailed();
}
mapping(address => uint256) public pendingWithdrawals;
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {ERC721Royalty} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Royalty.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract MyCollection is ERC721, ERC721Royalty, Ownable {
uint256 public constant MAX_SUPPLY = 10_000;
uint256 public constant MINT_PRICE = 0.01 ether;
uint256 public nextTokenId = 1;
string private _baseTokenURI;
error MaxSupplyReached();
error InsufficientPayment();
constructor(string memory baseURI, address royaltyReceiver, uint96 royaltyBps)
ERC721("MyCollection", "MYC")
Ownable(msg.sender)
{
_baseTokenURI = baseURI;
_setDefaultRoyalty(royaltyReceiver, royaltyBps); // EIP-2981
}
function mint() external payable {
if (nextTokenId > MAX_SUPPLY) revert MaxSupplyReached();
if (msg.value < MINT_PRICE) revert InsufficientPayment();
uint256 tokenId = nextTokenId++;
_safeMint(msg.sender, tokenId);
}
function _baseURI() internal view override returns (string memory) {
return _baseTokenURI;
}
function setBaseURI(string calldata baseURI) external onlyOwner {
_baseTokenURI = baseURI;
}
function withdraw() external onlyOwner {
(bool ok, ) = owner().call{value: address(this).balance}("");
require(ok, "Withdraw failed");
}
function supportsInterface(bytes4 interfaceId)
public view override(ERC721, ERC721Royalty)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract MyContractV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public counter;
function initialize(address _owner) public initializer {
__Ownable_init(_owner);
__UUPSUpgradeable_init();
}
function increment() external {
counter += 1;
}
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}
Déploiement avec forge script + OpenZeppelin Upgrades :
import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
address proxy = Upgrades.deployUUPSProxy(
"MyContractV1.sol",
abi.encodeCall(MyContractV1.initialize, (owner))
);
Important : pour les upgrades, le storage layout est critique. Toujours vérifier avec forge inspect et OZ Upgrades plugin.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console2} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";
contract MyTokenTest is Test {
MyToken token;
address owner = makeAddr("owner");
address alice = makeAddr("alice");
function setUp() public {
token = new MyToken(1_000_000 ether, owner);
}
function test_Mint() public {
vm.prank(owner);
token.mint(alice, 100 ether);
assertEq(token.balanceOf(alice), 100 ether);
}
function test_RevertWhen_NonOwnerMints() public {
vm.expectRevert();
vm.prank(alice);
token.mint(alice, 100 ether);
}
function test_RevertWhen_ExceedsMaxSupply() public {
vm.prank(owner);
vm.expectRevert(abi.encodeWithSelector(
MyToken.InsufficientBalance.selector, 2_000_000 ether, 1_000_000 ether
));
token.mint(alice, 2_000_000 ether);
}
}
function testFuzz_Mint(address to, uint96 amount) public {
vm.assume(to != address(0));
vm.assume(amount > 0 && amount <= 1_000_000 ether);
vm.prank(owner);
token.mint(to, amount);
assertEq(token.balanceOf(to), amount);
}
contract MyTokenInvariants is Test {
MyToken token;
function setUp() public {
token = new MyToken(1_000_000 ether, address(this));
}
function invariant_TotalSupplyNeverExceedsMax() public view {
assertLe(token.totalSupply(), token.maxSupply());
}
}
// BAD: 3 storage slots
struct UserBad {
uint256 balance; // slot 0
uint8 level; // slot 1 (gaspille 31 bytes)
address account; // slot 2
}
// GOOD: 2 storage slots
struct UserGood {
address account; // slot 0 (20 bytes)
uint8 level; // slot 0 (1 byte, packé)
uint96 balance; // slot 0 (12 bytes, packé)
// total: 33 bytes → ne tient pas, donc balance va sur slot 1
}
// Better:
struct UserBest {
uint128 balance; // slot 0 (16 bytes)
address account; // slot 0 (20 bytes) → trop, donc account va sur slot 1
uint8 level; // slot 1 (avec account)
}
uint256 public constant FEE_BPS = 100; // gravé dans le bytecode
address public immutable owner; // assigné au constructor, lecture cheap
uint256 public storageCounter; // SLOAD coûteux
// BAD: copy memory inutile
function processBad(uint256[] memory data) external { /* ... */ }
// GOOD: read directly from calldata
function processGood(uint256[] calldata data) external { /* ... */ }
function safeIncrement(uint256[] memory arr) public pure {
uint256 len = arr.length;
for (uint256 i; i < len;) {
arr[i] += 1;
unchecked { ++i; } // safe: i ne peut pas overflow car borné par len
}
}
// BAD: ~50 gas par char + storage du string
require(amount > 0, "Amount must be positive");
// GOOD: ~50 gas total
error AmountMustBePositive();
if (amount == 0) revert AmountMustBePositive();
event Transfer(address indexed from, address indexed to, uint256 amount);
event UserCreated(address indexed user, uint256 timestamp, string metadata);
indexed permet de filter sur cette propriété dans The Graph et les RPC eth_getLogs.
Sans events, la seule façon de query l'historique est de re-scanner toute la chaîne — impraticable. Les events sont la façon canonique de communiquer avec le off-chain.
nonReentrant sur les fonctions qui font des call externes → reentrancy attacks (The DAO, Cream Finance, etc.)transfer() ou send() → 2300 gas stipend insuffisant pour les nouveaux contracts. Utiliser call{value: x}("") avec check de retourtx.origin au lieu de msg.sender → phishing facileOwnable2Step → erreur de transfert irrécupérableunchecked dans les loops bornés → 30 gas perdu par itérationrequire(success) sans message → debug impossible_ prefix pour l'internal → confusion lecturescall{gas: 2300} → casse à la prochaine hard forkexternal payable ont nonReentrant ou justifient leur absenceimmutable / constant partout où c'est possibleforge snapshot --diff)smart-contract-security (ce plugin)web3-integration (ce plugin)blockchain-expert (ce plugin)