Solidity basique : contract, state et function
1. Aperçu général
Solidity est le langage principal pour écrire des smart contracts sur Ethereum et les chaînes compatibles EVM. Cet article vous aide à maîtriser les trois blocs de construction les plus fondamentaux : contract, les variables d’état et function — à travers un simple contrat Wallet. Une fois que vous aurez fini, vous saurez écrire un contrat capable de sauvegarder un état, contrôler l’accès avec des modificateurs, et écrire vos premiers tests avec Hardhat.
2. Qu’est-ce qu’un contract
En Solidity, un contract fonctionne comme une class en Java — il définit l’état (les données) et le comportement (les fonctions) qui seront déployés sur la blockchain à une adresse unique.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Wallet {
// les variables d'état et les fonctions iront ici
}
- La ligne
// SPDX-License-Identifierdéclare la licence ; le compilateur affichera un avertissement si elle est manquante. pragma solidity ^0.8.24verrouille la version du compilateur à partir de 0.8.24 (mais en dessous de 0.9.0).- Chaque contrat déployé aura sa propre adresse, comme un « objet » qui vit éternellement sur la chaîne.
Attention : Ne jamais utiliser un pragma aussi vague que
>=0.8.0 <0.9.0en production — les nouveaux compilateurs peuvent modifier le comportement par défaut (par exemple, la vérification des débordements).
3. Variable d’état — les données sauvegardées on-chain
Les variables d’état sont des variables écrites directement dans le stockage de la blockchain, qui persistent entre les appels de fonction et consomment du gas lors de l’écriture.
contract Wallet {
address public owner;
uint256 public balance;
mapping(address => uint256) public allowance;
constructor() {
owner = msg.sender;
}
}
address public owner: stocke l’adresse du propriétaire du contrat. Le mot-clépublicgénère automatiquement un getterowner()gratuit.uint256 public balance: le solde, un type d’entier non signé 256-bit — le type numérique le plus courant dans Solidity.mapping(address => uint256): comme unHashMap<Address, BigInteger>, mais les clés ne sont pas énumérables, seulement consultables par clé.constructor()s’exécute exactement une fois au déploiement,msg.senderest l’adresse qui exécute la transaction de déploiement.
3.1. Distinguer storage, memory, calldata
function updateAllowance(address spender, uint256 amount) external {
// amount est dans calldata (lecture seule, bon marché en gas)
allowance[spender] = amount; // écrit dans le storage
}
storage: données permanentes sur la chaîne (variables d’état).memory: données temporaires pendant l’exécution de la fonction, perdues après la fin de la fonction.calldata: commememorymais en lecture seule, utilisé pour les paramètres des fonctionsexternal— plus économe en gas quememory.
Attention : Oublier de déclarer
memory/calldatapour les paramètres de typearray,struct,stringcausera une erreur du compilateur — c’est très différent de Java, où vous n’avez pas besoin de gérer les zones mémoire.
4. Fonction et visibilité
Les fonctions en Solidity doivent obligatoirement déclarer leur visibilité explicitement, il n’y a pas de défaut « package-private » comme en Java.
contract Wallet {
address public owner;
uint256 public balance;
constructor() {
owner = msg.sender;
}
function deposit() external payable {
balance += msg.value;
}
function withdraw(uint256 amount) external {
require(msg.sender == owner, "Wallet: not owner");
require(amount <= balance, "Wallet: insufficient balance");
balance -= amount;
(bool success, ) = owner.call{value: amount}("");
require(success, "Wallet: transfer failed");
}
function getBalance() external view returns (uint256) {
return balance;
}
}
external: ne peut être appelée que de l’extérieur du contrat (par exemple via MetaMask, un autre contrat) — moins chère en gas quepublicquand les paramètres sont des arrays/strings volumineux.payable: permet à la fonction de recevoir de l’ETH avec la transaction ;msg.valueest le nombre de wei envoyés.view: promet de ne pas modifier l’état, ne consomme pas de gas quand elle est appelée hors-chaîne (viaeth_call).require(condition, "message"): vérifie une condition, annule toute la transaction avec un message si elle est fausse — similaire à lever uneIllegalArgumentException, mais annule aussi l’état et le gas consommé (sauf le gas de la vérification).owner.call{value: amount}(""): la façon recommandée d’envoyer l’ETH au lieu detransfer()/send()car elle n’impose pas une limite stricte de 2300 gas.
Les quatre niveaux de visibilité :
| Visibilité | Appelable de l’intérieur | Appelable d’un contrat enfant | Appelable de l’extérieur |
|---|---|---|---|
private |
✅ | ❌ | ❌ |
internal |
✅ | ✅ | ❌ |
public |
✅ | ✅ | ✅ |
external |
❌ (doit utiliser this.foo()) |
❌ | ✅ |
5. Modificateur — réutiliser les conditions de vérification
Répéter require(msg.sender == owner, ...) dans plusieurs fonctions est un signe qu’il faut l’extraire dans un modificateur.
contract Wallet {
address public owner;
uint256 public balance;
modifier onlyOwner() {
require(msg.sender == owner, "Wallet: not owner");
_;
}
constructor() {
owner = msg.sender;
}
function withdraw(uint256 amount) external onlyOwner {
require(amount <= balance, "Wallet: insufficient balance");
balance -= amount;
payable(owner).transfer(amount);
}
}
modifier onlyOwner()définit un bloc de vérification ;_;est l’emplacement où le corps de la fonction d’origine sera inséré.- Appliquer
onlyOwneraprès la déclaration dewithdrawrend le code lisible comme « seul le propriétaire peut retirer des fonds » — plus clair que de répéterrequiredans chaque fonction. - Les modificateurs peuvent accepter des paramètres, par exemple
modifier minAmount(uint256 amount) { require(amount > 0, "..."); _; }.
6. Événement — enregistrer les logs pour hors-chaîne
Un contrat ne peut pas retourner directement des données historiques à un frontend ; event est utilisé pour enregistrer des logs qu’une dApp peut écouter.
contract Wallet {
event Deposited(address indexed from, uint256 amount);
event Withdrawn(address indexed to, uint256 amount);
uint256 public balance;
function deposit() external payable {
balance += msg.value;
emit Deposited(msg.sender, msg.value);
}
}
event Deposited(...)déclare la structure du log, avec un maximum de 3 paramètresindexedpour faciliter le filtrage.emit Deposited(...)écrit le log dans le reçu de la transaction, bien moins cher en gas que de sauvegarder dans le stockage.- Un frontend utilise une bibliothèque comme ethers.js pour écouter
contract.on("Deposited", ...)et mettre à jour l’UI en temps réel.
7. Erreurs courantes quand vous apprenez Solidity
- Confondre
transfer/send/callpour envoyer l’ETH :transferlimite à 2300 gas, ce qui échoue facilement avec un contrat récepteur ayant une logique complexe (proxy, multisig). Utilisezcallavec une vérificationrequirecomme dans l’exemple à la section 4. - Oublier
requirepour valider les entrées : la validation manquante expose le contrat à des exploits (par exemple retrait négatif, débordement — bien que depuis Solidity 0.8 le débordement soit automatiquement annulé, la logique métier a besoin de sa propre validation). - Confondre
externaletpublicdans le mauvais contexte : utiliserpublicpour une fonction appelée uniquement de l’extérieur gaspille du gas quand les paramètres sont des arrays/strings, car Solidity doit copier les données enmemoryau lieu de les lire directement depuiscalldata. - Ne pas déclarer
view/purequand c’est possible : les outils comme les explorateurs de blocs et les portefeuilles ne reconnaissent pas une fonction en lecture seule, les utilisateurs la confondent avec une transaction payante.
Quand NE PAS laisser une fonction
public: si une fonction n’est jamais appelée depuis un autre contrat ou héritage, utilisezexternalpour économiser du gas et limiter la surface d’attaque.
8. Écrire des tests avec Hardhat
Ajoutez les dépendances pour un projet Hardhat (Node.js) :
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
Testez le comportement de withdraw qui ne permet qu’au propriétaire :
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Wallet", function () {
it("seul le propriétaire peut retirer des fonds", async function () {
const [owner, stranger] = await ethers.getSigners();
const Wallet = await ethers.getContractFactory("Wallet");
const wallet = await Wallet.deploy();
await wallet.connect(owner).deposit({ value: ethers.parseEther("1") });
await expect(
wallet.connect(stranger).withdraw(ethers.parseEther("1"))
).to.be.revertedWith("Wallet: not owner");
await expect(
wallet.connect(owner).withdraw(ethers.parseEther("1"))
).to.changeEtherBalance(owner, ethers.parseEther("1"));
});
});
ethers.getContractFactory("Wallet")compile et charge l’artifact du contrat.wallet.connect(stranger)simule une transaction envoyée par une adresse autre que le propriétaire.revertedWith("...")affirme que la transaction a été annulée avec exactement ce message durequire.changeEtherBalancevérifie que le solde ETH du propriétaire augmente de exactement 1 ETH après le retrait — une façon courante de tester au lieu de simplement vérifier la valeur de retour.
Exécutez les tests :
npx hardhat test
Wallet
✔ seul le propriétaire peut retirer des fonds (120ms)
1 passing (120ms)
9. Conclusion
Vous venez de parcourir quatre concepts fondamentaux de Solidity : contract comme unité de déploiement, les variables d’état pour stocker les données on-chain avec trois zones mémoire (storage/memory/calldata), les fonctions avec visibilité et modificateurs pour contrôler l’accès, et les événements pour communiquer avec hors-chaîne. Ces concepts forment le fondement pour progresser vers des sujets plus avancés comme l’héritage, les interfaces, ou les normes ERC-20/ERC-721 — tous construits sur ces quatre blocs.