Smart Contract Dev Process.
Guide de référence exhaustif pour le développement, le testing, l'audit et le déploiement de smart contracts sur RAAC Protocol — la source de vérité pour tout déploiement en production.
Stack & Structure de Projet
Framework
Hardhat · ethers.js v6 · TypeScript · Chai / Mocha
Sécurité
Slither · Mythril · solhint · hardhat-gas-reporter
Coverage
solidity-coverage · lcov · Istanbul thresholds
Déploiement
hardhat-deploy · Tenderly · Safe Multisig
Structure de répertoires
contracts/ ├── core/ # Contrats principaux (VotingEscrow, GaugeController…) ├── periphery/ # StableSwap, helpers, routers ├── interfaces/ # IVotingEscrow.sol, IGauge.sol… ├── libraries/ # Logique partagée (math, epoch…) ├── mocks/ # Mocks UNIQUEMENT pour tests └── upgradeable/ # Proxies UUPS / Beacon test/ ├── unit/ # 1 fichier de test par contrat │ ├── VotingEscrow.test.ts │ ├── GaugeController.test.ts │ └── StableSwap.test.ts ├── integration/ # Scénarios multi-contrats ├── fork/ # Tests sur fork mainnet └── fuzz/ # Propriétés invariantes scripts/ ├── deploy/ # 01_deploy_escrow.ts, 02_deploy_gauge.ts… ├── verify/ └── utils/ deployments/ # Adresses par réseau (générées par hardhat-deploy) audits/ # Rapports d'audit + réponses équipe docs/ # NatSpec exportée + architecture
Aucun contrat dans contracts/mocks/ ne doit jamais être référencé dans un script de déploiement production. Vérifier via grep -r "Mock" scripts/ avant chaque deploy.
Hardhat Configuration
import { HardhatUserConfig } from "hardhat/config"; import "@nomicfoundation/hardhat-toolbox"; import "hardhat-deploy"; import "hardhat-gas-reporter"; import "solidity-coverage"; import "@tenderly/hardhat-tenderly"; const config: HardhatUserConfig = { solidity: { compilers: [{ version: "0.8.20", settings: { optimizer: { enabled: true, runs: 200 }, viaIR: true, // active l'optimiseur via IR outputSelection: { "*": { "*": ["storageLayout"] } } } }] }, networks: { hardhat: { forking: { url: process.env.MAINNET_RPC_URL!, blockNumber: 19_500_000 // pin pour reproductibilité }, allowUnlimitedContractSize: false // simuler la limite 24KB }, mainnet: { url: process.env.MAINNET_RPC_URL!, accounts: [process.env.DEPLOYER_PK!] }, arbitrum: { url: process.env.ARB_RPC_URL!, accounts: [process.env.DEPLOYER_PK!] }, sepolia: { url: process.env.SEPOLIA_RPC_URL!, accounts: [process.env.DEPLOYER_PK!] } }, gasReporter: { enabled: process.env.REPORT_GAS === "true", currency: "USD", coinmarketcap: process.env.CMC_API_KEY, outputFile: "gas-report.txt", noColors: true }, coverage: { skipFiles: ["mocks/", "test/"] } }; export default config;
Variables d'environnement requises (.env)
MAINNET_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY ARB_RPC_URL=https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY DEPLOYER_PK= # Jamais la clé du multisig ETHERSCAN_API_KEY= CMC_API_KEY= # Pour gas reporter USD REPORT_GAS=false TENDERLY_ACCESS_KEY= TENDERLY_PROJECT=raac-protocol
Le DEPLOYER_PK ne doit JAMAIS être la clé propriétaire du multisig. Utiliser un hot wallet dédié avec des fonds minimaux. Les transactions importantes passent TOUJOURS par Safe Multisig.
Scripts NPM standards
"compile": "hardhat compile", "test": "hardhat test", "test:fork": "HARDHAT_FORK=mainnet hardhat test test/fork/**", "coverage": "hardhat coverage --solcoverjs .solcover.js", "gas": "REPORT_GAS=true hardhat test", "lint": "solhint 'contracts/**/*.sol'", "slither": "slither . --config-file slither.config.json", "deploy:testnet": "hardhat deploy --network sepolia --tags all", "deploy:mainnet": "hardhat deploy --network mainnet --tags all", "verify": "hardhat etherscan-verify --network mainnet", "size": "hardhat size-contracts"
Standards Solidity
Règles de nommage
| Élément | Convention | Exemple |
|---|---|---|
| Contract | PascalCase | VotingEscrow |
| Interface | I + PascalCase | IVotingEscrow |
| Library | PascalCase + Lib | EpochLib |
| Event | PascalCase | GaugeAdded |
| Error custom | PascalCase | InsufficientBalance |
| Function publique | camelCase | lockTokens |
| Variable private | _camelCase | _totalLocked |
| Constante | UPPER_SNAKE | MAX_LOCK_DURATION |
| Immutable | UPPER_SNAKE | START_TIME |
| Storage slot | UPPER_SNAKE_SLOT | STORAGE_SLOT |
Template de contrat avec NatSpec obligatoire
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; /// @title NomDuContrat /// @author RAAC Protocol /// @notice Description courte lisible par les utilisateurs /// @dev Détails techniques pour les développeurs. Mentionner /// les dépendances critiques et les invariants du contrat. contract NomDuContrat is Initializable, AccessControlUpgradeable { // ── ERRORS ──────────────────────────────────────────────── error Unauthorized(); error InvalidAmount(uint256 amount); error ZeroAddress(); // ── EVENTS ──────────────────────────────────────────────── /// @notice Emis lors d'un dépôt réussi /// @param user Adresse du déposant /// @param amount Montant en wei event Deposited(address indexed user, uint256 amount); // ── CONSTANTS ───────────────────────────────────────────── uint256 public constant MAX_LOCK = 4 * 365 days; // ── STATE ───────────────────────────────────────────────── /// @notice Total des tokens verrouillés dans le contrat uint256 public totalLocked; // ── CONSTRUCTOR / INITIALIZER ───────────────────────────── /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); } /// @notice Initialise le contrat (proxy pattern) /// @param _admin Adresse qui reçoit DEFAULT_ADMIN_ROLE function initialize(address _admin) external initializer { if (_admin == address(0)) revert ZeroAddress(); __AccessControl_init(); _grantRole(DEFAULT_ADMIN_ROLE, _admin); } // ── EXTERNAL ────────────────────────────────────────────── /// @notice Dépose des tokens /// @param amount Montant à déposer (>0) /// @return shares Nombre de shares créditées function deposit(uint256 amount) external returns (uint256 shares) { if (amount == 0) revert InvalidAmount(amount); // Checks → Effects → Interactions shares = _calculateShares(amount); totalLocked += amount; IERC20(token).transferFrom(msg.sender, address(this), amount); emit Deposited(msg.sender, amount); } }
Toute fonction external ou public doit avoir @notice + @param + @return. Les contrats sans NatSpec complète ne passent pas la revue de code. Générer la doc via hardhat docgen.
Patterns & Règles de Sécurité
Checks-Effects-Interactions (CEI) — obligatoire
function withdraw(uint256 amount) external nonReentrant { // 1. CHECKS — toutes les validations en premier if (amount == 0) revert InvalidAmount(amount); if (balances[msg.sender] < amount) revert InsufficientBalance(); // 2. EFFECTS — modifier l'état AVANT tout appel externe balances[msg.sender] -= amount; totalLocked -= amount; // 3. INTERACTIONS — appels externes EN DERNIER IERC20(token).transfer(msg.sender, amount); emit Withdrawn(msg.sender, amount); }
Règles d'accès aux rôles (OpenZeppelin AccessControl)
// Définir les rôles en bytes32 constants — jamais en string direct bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); bytes32 public constant EMERGENCY_ROLE = keccak256("EMERGENCY_ROLE"); bytes32 public constant GAUGE_ADMIN = keccak256("GAUGE_ADMIN"); // Toujours utiliser le modifier, pas onlyOwner (Ownable déprécié) function addGauge(address gauge) external onlyRole(GAUGE_ADMIN) { // ... } // Pattern emergency pause — le circuit breaker function pause() external onlyRole(EMERGENCY_ROLE) { _pause(); } function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { _unpause(); }
Patterns obligatoires par type de contrat
Token ERC-20 (RAAC)
ERC20Permit · ERC20Votes · nonReentrant sur mint/burn · cap sur supply
veToken (VotingEscrow)
Lock / unlock avec timestamps · checkpoint obligatoire · lecture bias/slope
Gauge
Epoch-based · lazy checkpoint · overflow sur intégrale de reward protégé
Upgradeable (proxy)
UUPS ou Transparent · storage gap 50 slots · _disableInitializers() constructor
Interdictions absolues
- UtiliserINTERDIT
tx.originpour les permissions - UtiliserINTERDIT
block.timestampseul pour l'aléatoire - Appel externe avant mise à jour d'état (reentrancy)INTERDIT
- Division avant multiplication (précision perdue)INTERDIT
- Déléguer à une adresse non validéeINTERDIT
delegatecall - Selfdestruct (déprécié EIP-6049)INTERDIT
- Modifier le storage layout d'un proxy sans migrationINTERDIT
- Déployer sansHIGH
nonReentrantsur toute fonction payable
Optimisation Gas
Règles d'optimisation de base
// ✓ Packer les variables storage (slot de 32 bytes) struct LockData { uint128 amount; // 16 bytes ─┐ même slot uint64 lockEnd; // 8 bytes ─┤ uint64 lockStart; // 8 bytes ─┘ } // ✓ Cache les variables storage dans des variables locales function computeReward() external view returns (uint256) { uint256 _totalSupply = totalSupply; // 1 SLOAD uint256 _rewardRate = rewardRate; // 1 SLOAD return _totalSupply * _rewardRate / 1e18; } // ✓ Custom errors < revert string (économie ~200 gas) error TooEarly(uint256 available); // ✓ require(block.timestamp >= end, "Too early"); // ✗ plus cher // ✓ unchecked pour les compteurs quand overflow impossible unchecked { for (uint256 i; i < length; ++i) { // ++i < i++ // ... } } // ✓ immutable > constant > storage pour les valeurs fixes address public immutable RAAC_TOKEN; // set dans constructor
Workflow gas reporting
# Générer un rapport gas avec prix USD REPORT_GAS=true npx hardhat test --grep "GaugeController" # Vérifier les tailles de contrats (limite: 24KB) npx hardhat size-contracts # Comparer avant/après une optimisation git stash && REPORT_GAS=true npx hardhat test > gas-before.txt git stash pop && REPORT_GAS=true npx hardhat test > gas-after.txt diff gas-before.txt gas-after.txt
Vérifier npx hardhat size-contracts sur chaque PR. Tout contrat dépassant 20 KB doit être réarchitecturé (split en libraries, proxy pattern, Diamond EIP-2535).
Stratégie de Tests
Template de test unitaire
import { expect } from "chai"; import { ethers } from "hardhat"; import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; import { time, loadFixture } from "@nomicfoundation/hardhat-network-helpers"; describe("VotingEscrow", function () { // ── FIXTURE ───────────────────────────────────────────── // Chaque describe réutilise le même fixture pour isolation async function deployFixture() { const [owner, alice, bob] = await ethers.getSigners(); const token = await ethers.deployContract("VotingEscrow"); await token.initialize(owner.address); return { token, owner, alice, bob }; } describe("#lockTokens", function () { it("reverts with InvalidAmount on zero", async function () { const { token, alice } = await loadFixture(deployFixture); await expect(token.connect(alice).lockTokens(0, MAX_LOCK)) .to.be.revertedWithCustomError(token, "InvalidAmount"); }); it("emits Locked event with correct args", async function () { const { token, alice } = await loadFixture(deployFixture); const amount = ethers.parseEther("100"); await expect(token.connect(alice).lockTokens(amount, MAX_LOCK)) .to.emit(token, "Locked") .withArgs(alice.address, amount, anyValue); }); it("increases totalLocked by amount", async function () { const { token, alice } = await loadFixture(deployFixture); const amount = ethers.parseEther("100"); const before = await token.totalLocked(); await token.connect(alice).lockTokens(amount, MAX_LOCK); expect(await token.totalLocked()).to.equal(before + amount); }); }); });
Règles de test à respecter
- UtiliserCRITICAL
loadFixturepour l'isolation des tests (pas debeforeEachavec déploiement) - 1HIGH
describepar fonction publique, 1itpar scénario distinct - Tester TOUS les chemins d'erreur avecHIGH
revertedWithCustomError - Tester les événements avecMED
emit+withArgs - Tester les limites: 0, max, max-1, overflow potentielHIGH
- Tester les transitions d'état avant et après chaque actionMED
- Tester le contrôle d'accès pour TOUS les rôles sur chaque fonction protégéeHIGH
- Tester les scénarios de reentrancy si contrat payableHIGH
Coverage Thresholds
Seuils minimums par type de métrique
module.exports = {
skipFiles: ['mocks/', 'test/', 'interfaces/'],
configureYulOptimizer: true,
// Seuils minimum — le CI échoue si non atteints
istanbulReporter: ['html', 'lcov', 'text'],
mocha: { timeout: 120_000 }
};"nyc": { "check-coverage": true, "lines": 95, "functions": 100, "branches": 90, "statements": 95 }
Aucune PR ne peut être mergée si la coverage tombe en dessous des seuils. Exceptions uniquement pour les fichiers explicitement exclus dans .solcover.js.
Fork Tests & Fuzz Testing
Fork test — intégration contre le mainnet réel
import { ethers } from "hardhat"; import { impersonateAccount, setBalance } from "@nomicfoundation/hardhat-network-helpers"; describe("StableSwap — Mainnet Fork", function () { this.timeout(120_000); // Fork tests sont lents it("swaps USDC → DAI via Curve 3pool", async function () { // Impersonate un whale USDC réel const whale = "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503"; await impersonateAccount(whale); await setBalance(whale, ethers.parseEther("10")); const signer = await ethers.getSigner(whale); const usdc = await ethers.getContractAt("IERC20", USDC_ADDRESS); const pool = await ethers.getContractAt("ICurvePool", CURVE_3POOL); const amountIn = ethers.parseUnits("1000", 6); // 1000 USDC await usdc.connect(signer).approve(pool.target, amountIn); // Curve exchange(i, j, dx, min_dy) — 3pool: 0=DAI, 1=USDC, 2=USDT const out = await pool.connect(signer).exchange(1, 0, amountIn, 0); // Vérifier le peg: 1 USDC ~ 1 DAI (± 0.1%) expect(out).to.be.closeTo( ethers.parseEther("1000"), ethers.parseEther("1") // 0.1% de tolérance ); }); });
Fuzz / Propriétés invariantes
import fc from "fast-check"; // npm i -D fast-check it("totalVotingPower >= 0 for any lock config", async function () { await fc.assert( fc.asyncProperty( fc.bigInt({ min: 1n, max: ethers.parseEther("1000000") }), fc.integer({ min: 1, max: 4 * 365 }), // durée en jours async (amount, days) => { const power = await escrow.computeVotingPower(amount, days); expect(power).to.be.gte(0); expect(power).to.be.lte(amount); // toujours <= amount } ), { numRuns: 500 } ); });
Checklist Pré-Audit
Vulnérabilités à vérifier manuellement
- Reentrancy — toutes les fonctions avec appels externes ontCRITICAL
nonReentrantou suivent CEI strict - Access Control — chaque fonction sensible a le bon modifier de rôle, testéCRITICAL
- Integer overflow — opérations arithmétiques protégées (Solidity 0.8+ ou unchecked justifié)CRITICAL
- Price oracle manipulation — pas d'utilisation de spot price sans TWAP ou ChainlinkCRITICAL
- Front-running — les fonctions sensibles (swap, liquidate) utilisent des slippage paramsHIGH
- Flash loan attack — les fonctions qui lisent des balances vérifient l'état du même blockHIGH
- Donation attack (ERC4626) — protection contre la manipulation du exchange rate au premier dépôtHIGH
- Storage collision — les proxies utilisent des storage slots isolés viaHIGH
ERC7201 - Initialization — tous les contrats upgradeables ontHIGH
_disableInitializers() - Precision loss — les divisions sont en dernier, pas de truncation prématuréeHIGH
- Timestamp dependency — aucune logique critique dépend deMED
block.timestampseul - Gas griefing — les boucles ont des limites ou sont unbounded sur des arrays contrôlésMED
- Events — chaque modification d'état émet un event avec les données indexéesMED
Checklist documentation audit
- NatSpec complète sur tous les contrats, fonctions, events et erreurs
- Diagramme d'architecture à jour (draw.io ou Mermaid)
- Liste des adresses de dépendances externes (oracles, tokens, multisigs)
- Rapport de coverage lcov disponible et > seuils
- Gas report généré et joint
- CHANGELOG des modifications depuis le dernier audit
- Inventaire complet des trusted roles et leurs permissions
Analyse Statique
{
"filter_paths": "node_modules,contracts/mocks,contracts/test",
"exclude_dependencies": true,
"checklist": true,
"sarif": "slither-report.sarif",
"detectors_to_exclude": "tautology,boolean-equality",
"printers_to_run": "human-summary,inheritance-graph,contract-summary"
}
{
"extends": "solhint:recommended",
"rules": {
"compiler-version": ["error", "^0.8.20"],
"func-visibility": ["error", { "ignoreConstructors": true }],
"no-unused-vars": "error",
"no-empty-blocks": "warn",
"custom-errors": "warn",
"named-parameters-mapping": "warn",
"avoid-call-value": "error"
}
}Le CI est configuré pour échouer si Slither détecte des issues de sévérité high ou critical. Les warnings Medium doivent être documentés et triés avant chaque déploiement.
Scripts de Déploiement
Ordre de déploiement RAAC Protocol
| Étape | Contrat | Dépendances | Tag hardhat-deploy |
|---|---|---|---|
| 01 | RAACToken | — | token |
| 02 | VotingEscrow | RAACToken | escrow |
| 03 | GaugeController | VotingEscrow | gauge-ctrl |
| 04 | GaugeRewardsDistributor | GaugeController | distributor |
| 05 | StableSwap | RAACToken + Oracle | pool |
| 06 | Setup Roles | Tous ci-dessus | setup |
import { HardhatRuntimeEnvironment } from "hardhat/types"; import { DeployFunction } from "hardhat-deploy/types"; const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { const { deployments, getNamedAccounts, network } = hre; const { deploy, get } = deployments; const { deployer, multisig } = await getNamedAccounts(); // Récupérer les dépendances déployées précédemment const raacToken = await get("RAACToken"); const result = await deploy("VotingEscrow", { from: deployer, proxy: { proxyContract: "UUPS", execute: { methodName: "initialize", args: [raacToken.address, multisig] // admin = multisig } }, log: true, autoMine: true, waitConfirmations: network.name === "mainnet" ? 5 : 1 }); // Vérifier sur Etherscan automatiquement if (result.newlyDeployed && network.name !== "hardhat") { await hre.run("verify:verify", { address: result.address, constructorArguments: [] }); } }; deploy.tags = ["escrow", "all"]; deploy.dependencies = ["token"]; // Hardhat-deploy gère l'ordre export default deploy;
Checklist de Déploiement
Avant le déploiement (pre-flight)
- Tests complets passent sur le réseau cible (fork au bon block)CRITICAL
- Coverage >= seuils sur tous les contrats à déployerCRITICAL
- Slither 0 warning high/critical sur le scope de déploiementCRITICAL
- Taille des contrats vérifiée (< 24KB viaHIGH
hardhat size-contracts) - Déploiement testé sur Sepolia/testnet avec le même scriptHIGH
- Adresses multisig Safe vérifiées (CRITICAL
getNamedAccountspointe vers le bon Safe) - Gas limit et gas price estimés — solde deployer suffisantHIGH
- Variables d'environnement production définies et vérifiéesHIGH
Après le déploiement (post-deploy)
- Vérification Etherscan réussie sur tous les contrats et implémentations proxyHIGH
- Adresses enregistrées dans
deployments/mainnet/via hardhat-deploy - Transfert admin vers Safe Multisig exécuté et confirmé on-chainCRITICAL
- Smoke tests post-déploiement (appels en lecture, 1 transaction test)HIGH
- Alertes Tenderly configurées sur les contrats déployésHIGH
- Mise à jour du fichier README avec les nouvelles adresses
- Tag git créé:
git tag deploy/mainnet/v1.x.x -a
CI/CD Pipeline (GitHub Actions)
name: CI — Smart Contracts on: push: { branches: [main, develop] } pull_request: { branches: [main, develop] } jobs: lint-and-compile: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20', cache: 'npm' } - run: npm ci - run: npm run lint - run: npm run compile test-and-coverage: needs: lint-and-compile runs-on: ubuntu-latest env: MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20', cache: 'npm' } - run: npm ci - run: npm run coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: { token: ${{ secrets.CODECOV_TOKEN }} } - name: Fail if coverage below threshold run: npx istanbul check-coverage --lines 95 --functions 100 slither: needs: lint-and-compile runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run Slither uses: crytic/slither-action@v0.4.0 with: sarif: results.sarif fail-on: high - uses: github/codeql-action/upload-sarif@v3 with: { sarif_file: results.sarif } gas-report: needs: test-and-coverage runs-on: ubuntu-latest env: MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} REPORT_GAS: "true" steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20', cache: 'npm' } - run: npm ci && npm run gas - uses: actions/upload-artifact@v4 with: { name: gas-report, path: gas-report.txt }
Branch protection rules
Toutes les branches main et develop requièrent: lint ✓ tests ✓ coverage ✓ slither ✓ avant merge.
Secrets GitHub requis
MAINNET_RPC_URL · CODECOV_TOKEN · ETHERSCAN_API_KEY · CMC_API_KEY
Monitoring Post-Déploiement
Alertes Tenderly à configurer
| Trigger | Sévérité | Action |
|---|---|---|
| Transfer admin role | CRITICAL | PagerDuty immédiat |
| Pause / Unpause | HIGH | Slack + équipe |
| Gauge added/killed | HIGH | Slack |
| Large pool swap (> $100k) | HIGH | Slack |
| Transaction failed on contract | MED | Slack |
| Gas usage anomaly (>50% baseline) | MED | Slack |
| Escrow totalLocked change > 5% | INFO | Dashboard |
Outils de monitoring
Incident Response
Circuit Breaker — procédure d'urgence
- Pause immédiate — AppelerSTEP 1
pause()via Safe Multisig si exploitation détectée - Notifier — Annoncer le problème sur Discord/Twitter + contact avec auditeurSTEP 2
- Analyser — Reproduire sur fork local, quantifier l'impact, identifier la root causeSTEP 3
- Patch — Développer le fix, tests, audit rapide (si possible) sur le correctifSTEP 4
- Upgrade ou Migration — Si proxy: upgrade via Safe. Sinon: migration vers nouveau contratSTEP 5
- Post-mortem — Rapport post-incident public dans 48h (root cause, impact, mesures prises)STEP 6
Maintenir un programme de bug bounty actif sur Immunefi ou HackerOne. Tout report de sévérité Critical ou High doit être traité en < 24h. Les fonds de récompense sont pré-approuvés par le multisig.