Ethereum pour les développeurs web : smart contracts sans le battage médiatique
Les concepts Ethereum essentiels : comptes, transactions, smart contracts, encodage ABI, ethers.js, WAGMI et lire des données on-chain sans faire tourner son propre nœud.
La plupart des contenus « Ethereum pour développeurs » tombent dans deux catégories : des analogies trop simplifiées qui ne vous aident pas à construire quoi que ce soit, ou des spécifications de protocole approfondies qui supposent que vous savez déjà ce qu'est un Merkle Patricia Trie. Aucune des deux n'est utile si vous êtes un développeur web qui veut lire un solde de jetons, laisser un utilisateur signer une transaction ou afficher des métadonnées NFT dans une application React.
Cet article est le juste milieu pratique. Je vais expliquer exactement ce qui se passe quand votre frontend communique avec Ethereum, quelles sont les pièces en mouvement, et comment les outils modernes (ethers.js, viem, WAGMI) correspondent à des concepts que vous comprenez déjà en construisant des applications web.
Pas de métaphores de distributeurs automatiques. Pas de « imaginez un monde où... ». Juste le modèle technique et le code.
Le modèle mental#
Ethereum est une machine à états répliquée. Chaque nœud du réseau maintient une copie identique de l'état — un immense magasin clé-valeur qui associe des adresses à des données de comptes. Quand vous « envoyez une transaction », vous proposez une transition d'état. Si suffisamment de validateurs s'accordent sur sa validité, l'état se met à jour. C'est tout.
L'état lui-même est simple. C'est une correspondance d'adresses de 20 octets vers des objets de compte. Chaque compte possède quatre champs :
- nonce : Combien de transactions ce compte a envoyées (pour les EOAs) ou combien de contrats il a créés (pour les comptes de contrat). Cela empêche les attaques par rejeu.
- balance : Montant d'ETH, libellé en wei (1 ETH = 10^18 wei). Toujours un grand entier.
- codeHash : Hash du bytecode EVM. Pour les portefeuilles ordinaires (EOAs), c'est le hash d'octets vides. Pour les contrats, c'est le hash du code déployé.
- storageRoot : Hash racine du trie de stockage du compte. Seuls les contrats ont un stockage significatif.
Il existe deux types de comptes, et la distinction est importante pour tout ce qui suit :
Les comptes détenus en externe (EOAs) sont contrôlés par une clé privée. C'est ce que MetaMask gère. Ils peuvent initier des transactions. Ils n'ont pas de code. Quand quelqu'un dit « portefeuille », il désigne un EOA.
Les comptes de contrat sont contrôlés par leur code. Ils ne peuvent pas initier de transactions — ils ne peuvent s'exécuter qu'en réponse à un appel. Ils ont du code et du stockage. Quand quelqu'un dit « smart contract », il désigne ceci. Le code est immuable une fois déployé (avec quelques exceptions via les patterns de proxy, ce qui est tout un autre sujet).
L'insight critique : chaque changement d'état sur Ethereum commence par un EOA qui signe une transaction. Les contrats peuvent appeler d'autres contrats, mais la chaîne d'exécution commence toujours par un humain (ou un bot) avec une clé privée.
Gas : le calcul a un prix#
Chaque opération dans l'EVM coûte du gas. Additionner deux nombres coûte 3 gas. Stocker un mot de 32 octets coûte 20 000 gas (première fois) ou 5 000 gas (mise à jour). Lire le stockage coûte 2 100 gas (à froid) ou 100 gas (à chaud, déjà accédé dans cette transaction).
Vous ne payez pas le gas en « unités de gas ». Vous payez en ETH. Le coût total est :
totalCost = gasUsed * gasPrice
Après l'EIP-1559 (mise à jour London), la tarification du gas est devenue un système en deux parties :
totalCost = gasUsed * (baseFee + priorityFee)
- baseFee : Fixé par le protocole en fonction de la congestion du réseau. Est brûlé (détruit).
- priorityFee (pourboire) : Va au validateur. Un pourboire plus élevé = une inclusion plus rapide.
- maxFeePerGas : Le maximum que vous êtes prêt à payer par unité de gas.
- maxPriorityFeePerGas : Le pourboire maximum par unité de gas.
Si baseFee + priorityFee > maxFeePerGas, votre transaction attend que le baseFee baisse. C'est pourquoi les transactions « restent bloquées » pendant les périodes de forte congestion.
L'implication pratique pour les développeurs web : lire des données est gratuit. Écrire des données coûte de l'argent. C'est la différence architecturale la plus importante entre le Web2 et le Web3. Chaque SELECT est gratuit. Chaque INSERT, UPDATE, DELETE coûte de l'argent réel. Concevez vos dApps en conséquence.
Transactions#
Une transaction est une structure de données signée. Voici les champs qui comptent :
interface Transaction {
// Qui reçoit cette transaction — une adresse EOA ou une adresse de contrat
to: string; // adresse hexadécimale de 20 octets, ou null pour le déploiement de contrat
// Combien d'ETH envoyer (en wei)
value: bigint; // Peut être 0n pour les appels de contrat purs
// Données d'appel de fonction encodées, ou vide pour les transferts ETH simples
data: string; // Octets encodés en hex, "0x" pour les transferts simples
// Compteur séquentiel, empêche les attaques par rejeu
nonce: number; // Doit être exactement égal au nonce actuel de l'expéditeur
// Limite de gas — gas maximum que cette tx peut consommer
gasLimit: bigint;
// Paramètres de frais EIP-1559
maxFeePerGas: bigint;
maxPriorityFeePerGas: bigint;
// Identifiant de chaîne (1 = mainnet, 11155111 = Sepolia, 137 = Polygon)
chainId: number;
}Le cycle de vie d'une transaction#
-
Construction : Votre application construit l'objet transaction. Si vous appelez une fonction de contrat, le champ
datacontient l'appel de fonction encodé en ABI (plus de détails ci-dessous). -
Signature : La clé privée signe la transaction encodée en RLP, produisant les composants de signature
v,r,s. Cela prouve que l'expéditeur a autorisé cette transaction spécifique. L'adresse de l'expéditeur est dérivée de la signature — elle n'est pas explicitement dans la transaction. -
Diffusion : La transaction signée est envoyée à un nœud RPC via
eth_sendRawTransaction. Le nœud la valide (nonce correct, solde suffisant, signature valide) et l'ajoute à son mempool. -
Mempool : La transaction attend dans un pool de transactions en attente. Les validateurs sélectionnent les transactions à inclure dans le prochain bloc, préférant généralement les pourboires plus élevés. C'est là que le front-running se produit — d'autres acteurs peuvent voir votre transaction en attente et soumettre la leur avec un pourboire plus élevé pour s'exécuter avant la vôtre.
-
Inclusion : Un validateur inclut votre transaction dans un bloc. L'EVM l'exécute. Si elle réussit, les changements d'état sont appliqués. Si elle revient (revert), les changements d'état sont annulés — mais vous payez quand même le gas consommé jusqu'au point de revert.
-
Finalité : Sur Ethereum proof-of-stake, un bloc devient « finalisé » après deux époques (~12,8 minutes). Avant la finalité, des réorganisations de chaîne sont théoriquement possibles (mais rares). La plupart des applications considèrent 1-2 confirmations de blocs comme « suffisantes » pour les opérations non critiques.
Voici à quoi ressemble l'envoi d'un simple transfert ETH avec ethers.js v6 :
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const wallet = new ethers.Wallet("0xYOUR_PRIVATE_KEY", provider);
const tx = await wallet.sendTransaction({
to: "0xRecipientAddress...",
value: ethers.parseEther("0.1"), // Convertit "0.1" en wei (100000000000000000n)
});
console.log("Hash de la tx :", tx.hash);
// Attendre l'inclusion dans un bloc
const receipt = await tx.wait();
console.log("Numéro de bloc :", receipt.blockNumber);
console.log("Gas utilisé :", receipt.gasUsed.toString());
console.log("Statut :", receipt.status); // 1 = succès, 0 = revertEt la même chose avec viem :
import { createWalletClient, http, parseEther } from "viem";
import { mainnet } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY");
const client = createWalletClient({
account,
chain: mainnet,
transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
const hash = await client.sendTransaction({
to: "0xRecipientAddress...",
value: parseEther("0.1"),
});
console.log("Hash de la tx :", hash);Notez la différence : ethers retourne un objet TransactionResponse avec une méthode .wait(). Viem retourne juste le hash — vous utilisez un appel séparé publicClient.waitForTransactionReceipt({ hash }) pour attendre la confirmation. Cette séparation des responsabilités est intentionnelle dans la conception de viem.
Smart contracts#
Un smart contract est du bytecode déployé plus du stockage persistant à une adresse spécifique. Quand vous « appelez » un contrat, vous envoyez une transaction (ou faites un appel en lecture seule) avec le champ data défini sur un appel de fonction encodé.
Bytecode et ABI#
Le bytecode est le code EVM compilé. Vous n'interagissez pas directement avec. C'est ce que l'EVM exécute.
L'ABI (Application Binary Interface) est une description JSON de l'interface du contrat. Elle dit à votre bibliothèque client comment encoder les appels de fonction et décoder les valeurs de retour. Pensez-y comme une spécification OpenAPI pour un contrat.
Voici un fragment d'un ABI de jeton ERC-20 :
const ERC20_ABI = [
// Fonctions en lecture seule (view/pure — pas de coût en gas quand appelées de l'extérieur)
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address owner) view returns (uint256)",
"function allowance(address owner, address spender) view returns (uint256)",
// Fonctions qui changent l'état (nécessitent une transaction, coûtent du gas)
"function transfer(address to, uint256 amount) returns (bool)",
"function approve(address spender, uint256 amount) returns (bool)",
"function transferFrom(address from, address to, uint256 amount) returns (bool)",
// Événements (émis pendant l'exécution, stockés dans les logs de transaction)
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Approval(address indexed owner, address indexed spender, uint256 value)",
] as const;Ethers.js accepte ce format d'« ABI lisible par l'humain ». Viem peut aussi l'utiliser, mais souvent vous travaillerez avec l'ABI JSON complète générée par le compilateur Solidity. Les deux sont équivalents — le format lisible est juste plus pratique pour les interfaces courantes.
Comment les appels de fonction sont encodés#
C'est la partie que la plupart des tutoriels sautent, et c'est celle qui vous fera gagner des heures de débogage.
Quand vous appelez transfer("0xBob...", 1000000), le champ data de la transaction est défini à :
0xa9059cbb // Sélecteur de fonction
0000000000000000000000000xBob...000000000000000000000000 // adresse, complétée à 32 octets
00000000000000000000000000000000000000000000000000000000000f4240 // montant uint256 (1000000 en hex)
Le sélecteur de fonction est les 4 premiers octets du hash Keccak-256 de la signature de la fonction :
keccak256("transfer(address,uint256)") = 0xa9059cbb...
sélecteur = 4 premiers octets = 0xa9059cbb
Les octets restants sont les arguments encodés en ABI, chacun complété à 32 octets. Ce schéma d'encodage est déterministe — le même appel de fonction produit toujours les mêmes calldata.
Pourquoi est-ce important ? Parce que quand vous voyez des données de transaction brutes sur Etherscan qui commencent par 0xa9059cbb, vous savez que c'est un appel transfer. Quand votre transaction revient et que le message d'erreur n'est qu'un blob hexadécimal, vous pouvez le décoder avec l'ABI. Et quand vous construisez des lots de transactions ou interagissez avec des contrats multicall, vous encoderez les calldata manuellement.
Voici comment encoder et décoder manuellement avec ethers.js :
import { ethers } from "ethers";
const iface = new ethers.Interface(ERC20_ABI);
// Encoder un appel de fonction
const calldata = iface.encodeFunctionData("transfer", [
"0xBobAddress...",
1000000n,
]);
console.log(calldata);
// 0xa9059cbb000000000000000000000000bob...000000000000000000000000000f4240
// Décoder les calldata en nom de fonction et arguments
const decoded = iface.parseTransaction({ data: calldata });
console.log(decoded.name); // "transfer"
console.log(decoded.args[0]); // "0xBobAddress..."
console.log(decoded.args[1]); // 1000000n (BigInt)
// Décoder les données de retour d'une fonction
const returnData = "0x0000000000000000000000000000000000000000000000000000000000000001";
const result = iface.decodeFunctionResult("transfer", returnData);
console.log(result[0]); // trueSlots de stockage#
Le stockage d'un contrat est un magasin clé-valeur où les clés et les valeurs font 32 octets. Solidity assigne les slots de stockage séquentiellement à partir de 0. La première variable d'état déclarée va dans le slot 0, la suivante dans le slot 1, et ainsi de suite. Les mappings et tableaux dynamiques utilisent un schéma basé sur le hash.
Vous pouvez lire directement le stockage de n'importe quel contrat, même si la variable est marquée private en Solidity. « Private » signifie seulement que les autres contrats ne peuvent pas la lire — n'importe qui peut la lire via eth_getStorageAt :
// Lire le slot de stockage 0 d'un contrat
const slot0 = await provider.getStorage(
"0xContractAddress...",
0
);
console.log(slot0); // Valeur hex brute de 32 octetsC'est comme ça que les explorateurs de blocs montrent l'état « interne » des contrats. Il n'y a pas de contrôle d'accès sur les lectures de stockage. La confidentialité sur une blockchain publique est fondamentalement limitée.
Événements et logs#
Les événements sont le moyen pour le contrat d'émettre des données structurées qui sont stockées dans les logs de transaction mais pas dans le stockage du contrat. Ils sont moins chers que les écritures en stockage (375 gas pour le premier topic + 8 gas par octet de données, contre 20 000 gas pour une écriture en stockage) et ils sont conçus pour être interrogés efficacement.
Un événement peut avoir jusqu'à 3 paramètres indexed (stockés comme « topics ») et un nombre illimité de paramètres non indexés (stockés comme « data »). Les paramètres indexés peuvent être filtrés — vous pouvez demander « donnez-moi tous les événements Transfer où to est cette adresse ». Les paramètres non indexés ne peuvent pas être filtrés ; vous devez récupérer tous les événements correspondants et filtrer côté client.
// Écouter les événements Transfer en temps réel avec ethers.js
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
contract.on("Transfer", (from, to, value, event) => {
console.log(`${from} -> ${to}: ${ethers.formatUnits(value, 18)} jetons`);
console.log("Bloc :", event.log.blockNumber);
console.log("Hash de tx :", event.log.transactionHash);
});
// Interroger les événements historiques
const filter = contract.filters.Transfer(null, "0xMyAddress..."); // from=quelconque, to=spécifique
const events = await contract.queryFilter(filter, 19000000, 19100000); // plage de blocs
for (const event of events) {
console.log("De :", event.args.from);
console.log("Valeur :", event.args.value.toString());
}La même chose avec viem :
import { createPublicClient, http, parseAbiItem } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
// Logs historiques
const logs = await client.getLogs({
address: "0xTokenAddress...",
event: parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
),
args: {
to: "0xMyAddress...",
},
fromBlock: 19000000n,
toBlock: 19100000n,
});
for (const log of logs) {
console.log("De :", log.args.from);
console.log("À :", log.args.to);
console.log("Valeur :", log.args.value);
}
// Surveillance en temps réel
const unwatch = client.watchEvent({
address: "0xTokenAddress...",
event: parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
),
onLogs: (logs) => {
for (const log of logs) {
console.log(`Transfert : ${log.args.from} -> ${log.args.to}`);
}
},
});
// Appeler unwatch() pour arrêter l'écouteLire des données on-chain#
C'est ici qu'Ethereum devient pratique pour les développeurs web. Vous n'avez pas besoin de faire tourner un nœud. Vous n'avez pas besoin de miner. Vous n'avez même pas besoin d'un portefeuille. Lire des données depuis Ethereum est gratuit, sans permission, et fonctionne via une simple API JSON-RPC.
JSON-RPC : l'API HTTP d'Ethereum#
Chaque nœud Ethereum expose une API JSON-RPC. C'est littéralement du HTTP POST avec des corps JSON. Il n'y a rien de spécifique à la blockchain dans la couche de transport.
// Voici ce que votre bibliothèque fait sous le capot
const response = await fetch("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_call",
params: [
{
to: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
data: "0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
// balanceOf(vitalik.eth)
},
"latest",
],
}),
});
const result = await response.json();
console.log(result);
// { jsonrpc: "2.0", id: 1, result: "0x000000000000000000000000000000000000000000000000000000174876e800" }C'est un eth_call brut. Il simule l'exécution d'une transaction sans réellement la soumettre. Pas de coût en gas. Pas de changement d'état. Il lit juste la valeur de retour. C'est comme ça que les fonctions view et pure fonctionnent de l'extérieur — elles utilisent eth_call au lieu de eth_sendRawTransaction.
Les deux méthodes RPC critiques#
eth_call : Simule l'exécution. Gratuit. Pas de changement d'état. Utilisé pour toutes les opérations de lecture — vérification de soldes, lecture de prix, appel de fonctions view. Peut être appelé sur n'importe quel bloc historique en spécifiant un numéro de bloc au lieu de "latest".
eth_sendRawTransaction : Soumet une transaction signée pour inclusion dans un bloc. Coûte du gas. Change l'état (en cas de succès). Utilisé pour toutes les opérations d'écriture — transferts, approbations, échanges, mints.
Tout le reste dans l'API JSON-RPC est soit une variante de ces deux méthodes, soit une méthode utilitaire (eth_blockNumber, eth_getTransactionReceipt, eth_getLogs, etc.).
Fournisseurs : votre passerelle vers la chaîne#
Vous ne faites pas tourner votre propre nœud. Presque personne ne le fait pour le développement d'applications. À la place, vous utilisez un service de fournisseur :
- Alchemy : Le plus populaire. Excellent tableau de bord, support de webhooks, APIs améliorées pour les NFTs et les métadonnées de jetons. Niveau gratuit : ~300M d'unités de calcul/mois.
- Infura : L'original. Propriété de ConsenSys. Fiable. Niveau gratuit : 100K requêtes/jour.
- QuickNode : Bon pour le multi-chaîne. Modèle de tarification légèrement différent.
- Endpoints RPC publics :
https://rpc.ankr.com/eth,https://cloudflare-eth.com. Gratuits mais limités en débit et parfois peu fiables. Bien pour le développement, dangereux pour la production. - Tenderly : Excellent pour la simulation et le débogage. Leur RPC inclut un simulateur de transactions intégré.
Pour la production, configurez toujours au moins deux fournisseurs en fallback. Les pannes RPC sont réelles et arriveront au pire moment possible.
import { ethers } from "ethers";
// Fournisseur fallback ethers.js v6
const provider = new ethers.FallbackProvider([
{
provider: new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/KEY1"),
priority: 1,
stallTimeout: 2000,
weight: 2,
},
{
provider: new ethers.JsonRpcProvider("https://mainnet.infura.io/v3/KEY2"),
priority: 2,
stallTimeout: 2000,
weight: 1,
},
]);Lire l'état des contrats gratuitement#
C'est le coup de maître que la plupart des développeurs Web2 ne réalisent pas : vous pouvez lire n'importe quelle donnée publique de n'importe quel contrat sur Ethereum sans rien payer, sans portefeuille, et sans aucune authentification au-delà d'une clé API pour votre fournisseur RPC.
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
// Interface ERC-20 — juste les fonctions de lecture
const erc20 = new ethers.Contract(
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
[
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address) view returns (uint256)",
],
provider // Note : provider, pas signer. Lecture seule.
);
const [name, symbol, decimals, totalSupply] = await Promise.all([
erc20.name(),
erc20.symbol(),
erc20.decimals(),
erc20.totalSupply(),
]);
console.log(`${name} (${symbol})`); // "USD Coin (USDC)"
console.log(`Décimales : ${decimals}`); // 6 (PAS 18 !)
console.log(`Supply total : ${ethers.formatUnits(totalSupply, decimals)}`);
// Vérifier le solde d'une adresse spécifique
const balance = await erc20.balanceOf("0xSomeAddress...");
console.log(`Solde : ${ethers.formatUnits(balance, decimals)} USDC`);Pas de portefeuille. Pas de gas. Pas de transaction. Juste un eth_call JSON-RPC sous le capot. C'est conceptuellement identique à faire une requête GET vers une API REST. La blockchain est la base de données, le contrat est l'API, et eth_call est votre requête SELECT.
ethers.js v6#
ethers.js est le jQuery du Web3 — c'était la première bibliothèque que la plupart des développeurs ont apprise, et c'est toujours la plus utilisée. La version 6 est une amélioration significative par rapport à la v5, avec le support natif de BigInt (enfin), des modules ESM et une API plus propre.
Les trois abstractions centrales#
Provider : Une connexion en lecture seule à la blockchain. Peut appeler des fonctions view, récupérer des blocs, lire des logs. Ne peut pas signer ni envoyer de transactions.
import { ethers } from "ethers";
// Se connecter à un nœud
const provider = new ethers.JsonRpcProvider("https://...");
// Requêtes de base
const blockNumber = await provider.getBlockNumber();
const balance = await provider.getBalance("0xAddress...");
const block = await provider.getBlock(blockNumber);
const txCount = await provider.getTransactionCount("0xAddress...");Signer : Une abstraction sur une clé privée. Peut signer des transactions et des messages. Un Signer est toujours connecté à un Provider.
// Depuis une clé privée (côté serveur, scripts)
const wallet = new ethers.Wallet("0xPrivateKey...", provider);
// Depuis un portefeuille navigateur (côté client)
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
// Obtenir l'adresse
const address = await signer.getAddress();Contract : Un proxy JavaScript pour un contrat déployé. Les méthodes de l'objet Contract correspondent aux fonctions de l'ABI. Les fonctions view retournent des valeurs. Les fonctions qui changent l'état retournent une TransactionResponse.
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
// Lecture (gratuit, retourne la valeur directement)
const balance = await usdc.balanceOf("0xSomeAddress...");
// balance est un bigint : 1000000000n (1000 USDC avec 6 décimales)
// Pour écrire, connecter avec un signer
const usdcWithSigner = usdc.connect(signer);
// Écriture (coûte du gas, retourne une TransactionResponse)
const tx = await usdcWithSigner.transfer("0xRecipient...", 1000000n);
const receipt = await tx.wait(); // Attendre l'inclusion dans un bloc
if (receipt.status === 0) {
throw new Error("Transaction annulée (revert)");
}TypeChain pour la sécurité de type#
Les interactions ABI brutes sont typées par des chaînes de caractères. Vous pouvez mal orthographier un nom de fonction, passer les mauvais types d'arguments ou mal interpréter les valeurs de retour. TypeChain génère des types TypeScript à partir de vos fichiers ABI :
// Sans TypeChain — pas de vérification de type
const balance = await contract.balanceOf("0x...");
// balance est 'any'. Pas d'autocomplétion. Facile à mal utiliser.
// Avec TypeChain — sécurité de type complète
import { USDC__factory } from "./typechain";
const usdc = USDC__factory.connect(USDC_ADDRESS, provider);
const balance = await usdc.balanceOf("0x...");
// balance est BigNumber. L'autocomplétion fonctionne. Les erreurs de type sont détectées à la compilation.Pour les nouveaux projets, envisagez d'utiliser l'inférence de type intégrée de viem à partir des ABIs. Elle atteint le même résultat sans étape de génération de code séparée.
Écouter les événements#
Le streaming d'événements en temps réel est critique pour des dApps réactives. ethers.js utilise des fournisseurs WebSocket pour cela :
// WebSocket pour les événements en temps réel
const wsProvider = new ethers.WebSocketProvider("wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const contract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, wsProvider);
// Écouter tous les événements Transfer
contract.on("Transfer", (from, to, value, event) => {
console.log(`Transfert : ${from} -> ${to}`);
console.log(`Montant : ${ethers.formatUnits(value, 6)} USDC`);
});
// Écouter les transferts VERS une adresse spécifique
const filter = contract.filters.Transfer(null, "0xMyAddress...");
contract.on(filter, (from, to, value) => {
console.log(`Transfert entrant : ${ethers.formatUnits(value, 6)} USDC de ${from}`);
});
// Nettoyer quand c'est fini
contract.removeAllListeners();WAGMI + Viem : la stack moderne#
WAGMI (We're All Gonna Make It) est une bibliothèque de hooks React pour Ethereum. Viem est le client TypeScript sous-jacent qu'elle utilise. Ensemble, ils ont largement remplacé ethers.js + web3-react comme stack standard pour le développement frontend de dApps.
Pourquoi ce changement ? Trois raisons : l'inférence TypeScript complète depuis les ABIs (pas de codegen nécessaire), une taille de bundle plus petite, et des hooks React qui gèrent la gestion d'état async compliquée des interactions wallet.
Mise en place#
// wagmi.config.ts
import { createConfig, http } from "wagmi";
import { mainnet, sepolia } from "wagmi/chains";
import { injected, walletConnect } from "wagmi/connectors";
export const config = createConfig({
chains: [mainnet, sepolia],
connectors: [
injected(),
walletConnect({ projectId: "YOUR_WALLETCONNECT_PROJECT_ID" }),
],
transports: {
[mainnet.id]: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
[sepolia.id]: http("https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY"),
},
});// app/providers.tsx
"use client";
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "./wagmi.config";
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</WagmiProvider>
);
}Lire les données d'un contrat#
useReadContract est le hook que vous utiliserez le plus. Il encapsule eth_call avec le cache React Query, le refetching et les états loading/error :
"use client";
import { useReadContract } from "wagmi";
import { formatUnits } from "viem";
const ERC20_ABI = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "owner", type: "address" }],
outputs: [{ name: "balance", type: "uint256" }],
},
] as const;
function TokenBalance({ address }: { address: `0x${string}` }) {
const { data: balance, isLoading, error } = useReadContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
abi: ERC20_ABI,
functionName: "balanceOf",
args: [address],
});
if (isLoading) return <span>Chargement...</span>;
if (error) return <span>Erreur : {error.message}</span>;
// balance est typé comme bigint parce que l'ABI dit uint256
return <span>{formatUnits(balance ?? 0n, 6)} USDC</span>;
}Notez le as const sur l'ABI. C'est critique. Sans lui, TypeScript perd les types littéraux et balance devient unknown au lieu de bigint. Tout le système d'inférence de type dépend des assertions const.
Écrire dans les contrats#
useWriteContract gère le cycle de vie complet : prompt du portefeuille, signature, diffusion et suivi de la confirmation.
"use client";
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { parseUnits } from "viem";
function SendTokens() {
const { writeContract, data: hash, isPending, error } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash,
});
function handleSend() {
writeContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: ERC20_ABI,
functionName: "transfer",
args: [
"0xRecipientAddress...",
parseUnits("100", 6), // 100 USDC
],
});
}
return (
<div>
<button onClick={handleSend} disabled={isPending}>
{isPending ? "Confirmez dans le portefeuille..." : "Envoyer 100 USDC"}
</button>
{hash && <p>Transaction : {hash}</p>}
{isConfirming && <p>En attente de confirmation...</p>}
{isSuccess && <p>Transfert confirmé !</p>}
{error && <p>Erreur : {error.message}</p>}
</div>
);
}Surveiller les événements#
useWatchContractEvent met en place un abonnement WebSocket pour la surveillance d'événements en temps réel :
"use client";
import { useWatchContractEvent } from "wagmi";
import { useState } from "react";
import { formatUnits } from "viem";
interface TransferEvent {
from: string;
to: string;
value: string;
}
function LiveTransfers() {
const [transfers, setTransfers] = useState<TransferEvent[]>([]);
useWatchContractEvent({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: ERC20_ABI,
eventName: "Transfer",
onLogs(logs) {
const newTransfers = logs.map((log) => ({
from: log.args.from as string,
to: log.args.to as string,
value: formatUnits(log.args.value as bigint, 6),
}));
setTransfers((prev) => [...newTransfers, ...prev].slice(0, 50));
},
});
return (
<ul>
{transfers.map((t, i) => (
<li key={i}>
{t.from.slice(0, 8)}... → {t.to.slice(0, 8)}... : {t.value} USDC
</li>
))}
</ul>
);
}Patterns de connexion de portefeuille#
Connecter le portefeuille d'un utilisateur est le « login » du Web3. Sauf que ce n'est pas un login. Il n'y a pas de session, pas de cookie, pas d'état côté serveur. La connexion du portefeuille donne à votre application la permission de lire l'adresse de l'utilisateur et de demander des signatures de transaction. C'est tout.
L'interface fournisseur EIP-1193#
Chaque portefeuille expose une interface standard définie par l'EIP-1193. C'est un objet avec une méthode request :
interface EIP1193Provider {
request(args: { method: string; params?: unknown[] }): Promise<unknown>;
on(event: string, handler: (...args: unknown[]) => void): void;
removeListener(event: string, handler: (...args: unknown[]) => void): void;
}MetaMask l'injecte comme window.ethereum. D'autres portefeuilles injectent soit leur propre propriété, soit utilisent aussi window.ethereum (ce qui cause des conflits — le problème des « guerres de portefeuilles », partiellement résolu par l'EIP-6963).
// Interaction bas niveau avec le portefeuille (vous ne devriez pas le faire directement, mais c'est utile à comprendre)
// Demander l'accès au compte
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
console.log("Adresse connectée :", accounts[0]);
// Obtenir la chaîne actuelle
const chainId = await window.ethereum.request({
method: "eth_chainId",
});
console.log("ID de chaîne :", parseInt(chainId, 16)); // "0x1" -> 1 (mainnet)
// Écouter les changements de compte (l'utilisateur change de compte dans MetaMask)
window.ethereum.on("accountsChanged", (accounts: string[]) => {
if (accounts.length === 0) {
console.log("Portefeuille déconnecté");
} else {
console.log("Basculé vers :", accounts[0]);
}
});
// Écouter les changements de chaîne (l'utilisateur change de réseau)
window.ethereum.on("chainChanged", (chainId: string) => {
// L'approche recommandée est de recharger la page
window.location.reload();
});EIP-6963 : découverte multi-portefeuille#
L'ancienne approche window.ethereum casse quand les utilisateurs ont plusieurs portefeuilles installés. Lequel obtient window.ethereum ? Le dernier à s'injecter ? Le premier ? C'est une condition de course.
L'EIP-6963 corrige cela avec un protocole de découverte basé sur les événements du navigateur :
// Découvrir tous les portefeuilles disponibles
interface EIP6963ProviderDetail {
info: {
uuid: string;
name: string;
icon: string;
rdns: string; // Nom de domaine inversé, par ex. "io.metamask"
};
provider: EIP1193Provider;
}
const wallets: EIP6963ProviderDetail[] = [];
window.addEventListener("eip6963:announceProvider", (event: CustomEvent) => {
wallets.push(event.detail);
});
// Demander à tous les portefeuilles de s'annoncer
window.dispatchEvent(new Event("eip6963:requestProvider"));
// Maintenant 'wallets' contient tous les portefeuilles installés avec leurs noms et icônes
// Vous pouvez afficher une interface de sélection de portefeuilleWAGMI gère tout cela pour vous. Quand vous utilisez le connecteur injected(), il utilise automatiquement l'EIP-6963 si disponible et revient à window.ethereum sinon.
WalletConnect#
WalletConnect est un protocole qui connecte les portefeuilles mobiles aux dApps desktop via un serveur relais. L'utilisateur scanne un QR code avec son portefeuille mobile, établissant une connexion chiffrée. Les requêtes de transaction sont relayées de votre dApp à leur téléphone.
Avec WAGMI, c'est juste un autre connecteur :
import { walletConnect } from "wagmi/connectors";
const connector = walletConnect({
projectId: "YOUR_PROJECT_ID", // Obtenir sur cloud.walletconnect.com
showQrModal: true,
});Gérer le changement de chaîne#
Les utilisateurs sont souvent sur le mauvais réseau. Votre dApp est sur le Mainnet, ils sont connectés à Sepolia. Ou ils sont sur Polygon et vous avez besoin du Mainnet. WAGMI fournit useSwitchChain :
"use client";
import { useAccount, useSwitchChain } from "wagmi";
import { mainnet } from "wagmi/chains";
function NetworkGuard({ children }: { children: React.ReactNode }) {
const { chain } = useAccount();
const { switchChain, isPending } = useSwitchChain();
if (!chain) return <p>Veuillez connecter votre portefeuille</p>;
if (chain.id !== mainnet.id) {
return (
<div>
<p>Veuillez basculer vers Ethereum Mainnet</p>
<button
onClick={() => switchChain({ chainId: mainnet.id })}
disabled={isPending}
>
{isPending ? "Changement en cours..." : "Changer de réseau"}
</button>
</div>
);
}
return <>{children}</>;
}IPFS et métadonnées#
Les NFTs ne stockent pas les images on-chain. La blockchain stocke un URI qui pointe vers un fichier JSON de métadonnées, qui à son tour contient une URL vers l'image. Le pattern standard, défini par la fonction tokenURI de l'ERC-721 :
Contract.tokenURI(42) → "ipfs://QmXyz.../42.json"
Ce fichier JSON suit un schéma standard :
{
"name": "Cool NFT #42",
"description": "Un NFT très cool",
"image": "ipfs://QmImageHash...",
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Rarity", "value": "Legendary" }
]
}CID IPFS vs URL#
Les adresses IPFS utilisent des Content Identifiers (CIDs) — des hashs du contenu lui-même. ipfs://QmXyz... signifie « le contenu dont le hash est QmXyz... ». C'est du stockage adressé par le contenu : l'URI est dérivé du contenu, donc le contenu ne peut jamais changer sans changer l'URI. C'est la garantie d'immutabilité sur laquelle les NFTs s'appuient (quand ils utilisent réellement IPFS — beaucoup utilisent des URLs centralisées à la place, ce qui est un signal d'alarme).
Pour afficher du contenu IPFS dans un navigateur, vous avez besoin d'une passerelle qui traduit les URIs IPFS en HTTP :
function ipfsToHttp(uri: string): string {
if (uri.startsWith("ipfs://")) {
const cid = uri.replace("ipfs://", "");
return `https://ipfs.io/ipfs/${cid}`;
// Ou utilisez une passerelle dédiée :
// return `https://YOUR_PROJECT.mypinata.cloud/ipfs/${cid}`;
}
return uri;
}
// Récupérer les métadonnées NFT
async function getNftMetadata(
contractAddress: string,
tokenId: bigint,
provider: ethers.Provider
) {
const contract = new ethers.Contract(
contractAddress,
["function tokenURI(uint256 tokenId) view returns (string)"],
provider
);
const tokenUri = await contract.tokenURI(tokenId);
const httpUri = ipfsToHttp(tokenUri);
const response = await fetch(httpUri);
const metadata = await response.json();
return {
name: metadata.name,
description: metadata.description,
image: ipfsToHttp(metadata.image),
attributes: metadata.attributes,
};
}Services de pinning#
IPFS est un réseau pair-à-pair. Le contenu ne reste disponible que tant que quelqu'un l'héberge (le « pin »). Si vous uploadez une image NFT sur IPFS puis éteignez votre nœud, le contenu disparaît.
Les services de pinning gardent votre contenu disponible :
- Pinata : Le plus populaire. API simple. Niveau gratuit généreux (1 Go). Passerelles dédiées pour un chargement plus rapide.
- NFT.Storage : Gratuit, soutenu par Protocol Labs (les créateurs d'IPFS). Conçu spécifiquement pour les métadonnées NFT. Utilise Filecoin pour la persistance à long terme.
- Web3.Storage : Similaire à NFT.Storage, plus généraliste.
// Upload sur Pinata
async function pinToIpfs(file: File): Promise<string> {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
method: "POST",
headers: {
Authorization: `Bearer ${PINATA_JWT}`,
},
body: formData,
});
const result = await response.json();
return `ipfs://${result.IpfsHash}`; // Retourne le CID
}Le problème de l'indexation#
Voici le secret honteux du développement blockchain : vous ne pouvez pas interroger efficacement les données historiques depuis un nœud RPC.
Vous voulez tous les événements Transfer d'un jeton de l'année dernière ? Vous devrez scanner des millions de blocs avec eth_getLogs, en paginant par tranches de 2 000-10 000 blocs (le maximum varie selon le fournisseur). Ce sont des milliers d'appels RPC. Cela prendra des minutes voire des heures et consommera votre quota d'API.
Vous voulez tous les jetons détenus par une adresse spécifique ? Il n'y a pas d'appel RPC unique pour cela. Vous devriez scanner chaque événement Transfer de chaque contrat ERC-20 en suivant les soldes. Ce n'est pas faisable.
Vous voulez tous les NFTs d'un portefeuille ? Même problème. Vous devez scanner chaque événement Transfer ERC-721 de chaque contrat NFT.
La blockchain est une structure de données optimisée pour l'écriture. Elle est excellente pour traiter de nouvelles transactions. Elle est terrible pour répondre à des requêtes historiques. C'est le décalage fondamental entre ce dont les interfaces de dApps ont besoin et ce que la chaîne fournit nativement.
Le protocole The Graph#
The Graph est un protocole d'indexation décentralisé. Vous écrivez un « subgraph » — un schéma et un ensemble de gestionnaires d'événements — et The Graph indexe la chaîne et sert les données via une API GraphQL.
# Schéma du subgraph (schema.graphql)
type Transfer @entity {
id: Bytes!
from: Bytes!
to: Bytes!
value: BigInt!
blockNumber: BigInt!
timestamp: BigInt!
}
type Account @entity {
id: Bytes!
balance: BigInt!
transfersFrom: [Transfer!]! @derivedFrom(field: "from")
transfersTo: [Transfer!]! @derivedFrom(field: "to")
}// Interroger un subgraph depuis votre frontend
const SUBGRAPH_URL =
"https://api.studio.thegraph.com/query/YOUR_ID/YOUR_SUBGRAPH/v0.0.1";
async function getRecentTransfers(address: string) {
const query = `
query GetTransfers($address: Bytes!) {
transfers(
where: { from: $address }
orderBy: blockNumber
orderDirection: desc
first: 100
) {
id
from
to
value
blockNumber
timestamp
}
}
`;
const response = await fetch(SUBGRAPH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables: { address } }),
});
const { data } = await response.json();
return data.transfers;
}Le compromis : The Graph ajoute de la latence (typiquement 1-2 blocs derrière la tête de chaîne) et une dépendance supplémentaire. Le réseau décentralisé a aussi des coûts d'indexation (vous payez en jetons GRT). Pour les projets plus petits, le service hébergé (Subgraph Studio) est gratuit.
APIs améliorées Alchemy et Moralis#
Si vous ne voulez pas maintenir un subgraph, Alchemy et Moralis offrent des APIs pré-indexées qui répondent directement aux requêtes courantes :
// Alchemy : Obtenir tous les soldes de jetons ERC-20 pour une adresse
const response = await fetch(
`https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "alchemy_getTokenBalances",
params: ["0xAddress...", "erc20"],
}),
}
);
// Retourne TOUS les soldes de jetons ERC-20 en un seul appel
// vs. scanner le balanceOf() de chaque contrat ERC-20 possible// Alchemy : Obtenir tous les NFTs détenus par une adresse
const response = await fetch(
`https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY/getNFTs?owner=0xAddress...`
);
const { ownedNfts } = await response.json();
for (const nft of ownedNfts) {
console.log(`${nft.title} - ${nft.contract.address}#${nft.tokenId}`);
}Ces APIs sont propriétaires et centralisées. Vous échangez la décentralisation contre l'expérience développeur. Pour la plupart des dApps, c'est un compromis qui en vaut la peine. Vos utilisateurs ne se soucient pas de savoir si leur vue de portefeuille vient d'un subgraph ou de la base de données d'Alchemy. Ce qui leur importe, c'est que ça se charge en 200 ms au lieu de 30 secondes.
Pièges courants#
Après avoir livré plusieurs dApps en production et débogué le code d'autres équipes, voici les erreurs que je vois se répéter. Chacune d'entre elles m'a personnellement mordu.
BigInt partout#
Ethereum manipule de très grands nombres. Les soldes ETH sont en wei (10^18). Les supplies de jetons peuvent atteindre 10^27 ou plus. Le Number JavaScript ne peut représenter de manière sûre que les entiers jusqu'à 2^53 - 1 (environ 9 * 10^15). Ce n'est pas suffisant pour les montants en wei.
// FAUX — perte de précision silencieuse
const balance = 1000000000000000000; // 1 ETH en wei
const double = balance * 2;
console.log(double); // 2000000000000000000 — semble correct, mais...
const largeBalance = 99999999999999999999; // ~100 ETH
console.log(largeBalance); // 100000000000000000000 — FAUX ! Arrondi vers le haut.
console.log(largeBalance === 100000000000000000000); // true — corruption de données
// CORRECT — utiliser BigInt
const balance = 1000000000000000000n;
const double = balance * 2n;
console.log(double.toString()); // "2000000000000000000" — correct
const largeBalance = 99999999999999999999n;
console.log(largeBalance.toString()); // "99999999999999999999" — correctRègles pour BigInt dans le code dApp :
- Ne jamais convertir les montants en wei en
Number. UtilisezBigIntpartout, convertissez en chaînes lisibles par l'humain uniquement pour l'affichage. - Ne jamais utiliser
Math.floor,Math.round, etc. sur les BigInts. Ils ne fonctionnent pas. Utilisez la division entière :amount / 10n ** 6n. - JSON ne supporte pas BigInt. Si vous sérialisez un état qui contient des BigInts, vous avez besoin d'un sérialiseur personnalisé :
JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v). - Utilisez les fonctions de formatage des bibliothèques.
ethers.formatEther(),ethers.formatUnits(),formatEther()etformatUnits()de viem. Elles gèrent la conversion correctement.
import { formatUnits, parseUnits } from "viem";
// Affichage : BigInt → chaîne lisible par l'humain
const weiAmount = 1500000000000000000n; // 1.5 ETH
const display = formatUnits(weiAmount, 18); // "1.5"
// Saisie : chaîne lisible par l'humain → BigInt
const userInput = "1.5";
const wei = parseUnits(userInput, 18); // 1500000000000000000n
// USDC a 6 décimales, pas 18
const usdcAmount = 100000000n; // 100 USDC
const usdcDisplay = formatUnits(usdcAmount, 6); // "100.0"Opérations de portefeuille asynchrones#
Chaque interaction avec le portefeuille est asynchrone et peut échouer de manières que votre application doit gérer avec élégance :
// L'utilisateur peut rejeter n'importe quel prompt du portefeuille
try {
const tx = await writeContract({
address: contractAddress,
abi: ERC20_ABI,
functionName: "approve",
args: [spenderAddress, amount],
});
} catch (error) {
if (error.code === 4001) {
// L'utilisateur a rejeté la transaction dans son portefeuille
// C'est normal — pas une erreur à signaler
showToast("Transaction annulée");
} else if (error.code === -32603) {
// Erreur JSON-RPC interne — signifie souvent que la transaction échouerait
showToast("La transaction échouerait. Vérifiez votre solde.");
} else {
// Erreur inattendue
console.error("Erreur de transaction :", error);
showToast("Quelque chose s'est mal passé. Veuillez réessayer.");
}
}Pièges async principaux :
- Les prompts du portefeuille sont bloquants côté utilisateur. Le
awaitdans votre code peut prendre 30 secondes pendant que l'utilisateur lit les détails de la transaction dans MetaMask. N'affichez pas un spinner de chargement qui leur fait penser que quelque chose est cassé. - L'utilisateur peut changer de compte en cours d'interaction. Vous demandez une approbation depuis le Compte A, l'utilisateur bascule vers le Compte B, puis approuve. Maintenant le Compte B a approuvé mais vous êtes sur le point d'envoyer une transaction depuis le Compte A. Revérifiez toujours le compte connecté avant les opérations critiques.
- Les patterns d'écriture en deux étapes sont courants. Beaucoup d'opérations DeFi nécessitent
approve+execute. L'utilisateur doit signer deux transactions. S'il approuve mais n'exécute pas, vous devez vérifier l'état de l'allowance et sauter l'étape d'approbation la prochaine fois.
Erreurs de décalage de réseau#
Celle-ci gaspille plus de temps de débogage que tout autre problème. Votre contrat est sur le Mainnet. Votre portefeuille est sur Sepolia. Votre fournisseur RPC pointe vers Polygon. Trois réseaux différents, trois états différents, trois blockchains complètement indépendantes. Et le message d'erreur est généralement inutile — « execution reverted » ou « contract not found ».
// Vérification défensive de la chaîne
import { useAccount, useChainId } from "wagmi";
function useRequireChain(requiredChainId: number) {
const chainId = useChainId();
const { isConnected } = useAccount();
if (!isConnected) {
return { ready: false, error: "Veuillez connecter votre portefeuille" };
}
if (chainId !== requiredChainId) {
return {
ready: false,
error: `Veuillez basculer vers ${getChainName(requiredChainId)}. Vous êtes sur ${getChainName(chainId)}.`,
};
}
return { ready: true, error: null };
}Front-running en DeFi#
Quand vous soumettez un échange sur un DEX, votre transaction en attente est visible dans le mempool. Un bot peut voir votre trade, le devancer en poussant le prix vers le haut, laisser votre trade s'exécuter à un prix défavorable, puis vendre immédiatement après pour un profit. C'est ce qu'on appelle une « attaque sandwich ».
En tant que développeur frontend, vous ne pouvez pas empêcher cela entièrement, mais vous pouvez l'atténuer :
// Définir la tolérance de slippage sur un échange de type Uniswap
const amountOutMin = expectedOutput * 995n / 1000n; // 0,5 % de tolérance de slippage
// Utiliser un deadline pour empêcher les transactions en attente de longue durée
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 20); // 20 minutes
await router.swapExactTokensForTokens(
amountIn,
amountOutMin, // Sortie minimale acceptable — revert si on obtiendrait moins
[tokenA, tokenB],
userAddress,
deadline, // Revert si pas exécuté dans les 20 minutes
);Pour les transactions de haute valeur, envisagez d'utiliser le RPC Flashbots Protect, qui envoie les transactions directement aux constructeurs de blocs au lieu du mempool public. Cela empêche complètement les attaques sandwich parce que les bots ne voient jamais votre transaction en attente :
// Utiliser Flashbots Protect comme endpoint RPC
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");Confusion des décimales#
Tous les jetons n'ont pas 18 décimales. USDC et USDT en ont 6. WBTC en a 8. Certains jetons ont 0, 2 ou des décimales arbitraires. Lisez toujours les decimals() du contrat avant de formater les montants :
async function formatTokenAmount(
tokenAddress: string,
rawAmount: bigint,
provider: ethers.Provider
): Promise<string> {
const contract = new ethers.Contract(
tokenAddress,
["function decimals() view returns (uint8)", "function symbol() view returns (string)"],
provider
);
const [decimals, symbol] = await Promise.all([
contract.decimals(),
contract.symbol(),
]);
return `${ethers.formatUnits(rawAmount, decimals)} ${symbol}`;
}
// formatTokenAmount(USDC, 1000000n, provider) → "1.0 USDC"
// formatTokenAmount(WETH, 1000000000000000000n, provider) → "1.0 WETH"
// formatTokenAmount(WBTC, 100000000n, provider) → "1.0 WBTC"Échecs d'estimation de gas#
Quand estimateGas échoue, cela signifie généralement que la transaction échouerait (revert). Mais le message d'erreur est souvent juste « cannot estimate gas » sans indication de la raison. Utilisez eth_call pour simuler la transaction et obtenir la vraie raison du revert :
import { createPublicClient, http, decodeFunctionResult } from "viem";
async function simulateAndGetError(client: ReturnType<typeof createPublicClient>, tx: object) {
try {
await client.call({
account: tx.from,
to: tx.to,
data: tx.data,
value: tx.value,
});
return null; // Pas d'erreur — la transaction réussirait
} catch (error) {
// Décoder la raison du revert
if (error.data) {
// Chaînes de revert courantes
if (error.data.startsWith("0x08c379a0")) {
// Error(string) — revert standard avec message
const reason = decodeAbiParameters(
[{ type: "string" }],
`0x${error.data.slice(10)}`
);
return `Revert : ${reason[0]}`;
}
}
return error.message;
}
}Tout assembler#
Voici un composant React complet et minimal qui connecte un portefeuille, lit un solde de jeton et envoie un transfert. C'est le squelette de chaque dApp :
"use client";
import { useAccount, useConnect, useDisconnect, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { injected } from "wagmi/connectors";
import { formatUnits, parseUnits } from "viem";
import { useState } from "react";
const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const USDC_ABI = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
{
name: "transfer",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
] as const;
export function TokenDashboard() {
const { address, isConnected } = useAccount();
const { connect } = useConnect();
const { disconnect } = useDisconnect();
const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
// Lire le solde — ne s'exécute que quand l'adresse est définie
const { data: balance, refetch: refetchBalance } = useReadContract({
address: USDC_ADDRESS,
abi: USDC_ABI,
functionName: "balanceOf",
args: address ? [address] : undefined,
query: { enabled: !!address },
});
// Écriture : transférer des jetons
const {
writeContract,
data: txHash,
isPending: isSigning,
error: writeError,
} = useWriteContract();
// Attendre la confirmation
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
// Rafraîchir le solde après confirmation
if (isSuccess) {
refetchBalance();
}
if (!isConnected) {
return (
<button onClick={() => connect({ connector: injected() })}>
Connecter le portefeuille
</button>
);
}
return (
<div>
<p>Connecté : {address}</p>
<p>
Solde USDC :{" "}
{balance !== undefined ? formatUnits(balance, 6) : "Chargement..."}
</p>
<div>
<input
placeholder="Adresse du destinataire (0x...)"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
<input
placeholder="Montant (ex : 100)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<button
onClick={() => {
writeContract({
address: USDC_ADDRESS,
abi: USDC_ABI,
functionName: "transfer",
args: [recipient as `0x${string}`, parseUnits(amount, 6)],
});
}}
disabled={isSigning || isConfirming}
>
{isSigning
? "Confirmez dans le portefeuille..."
: isConfirming
? "Confirmation en cours..."
: "Envoyer des USDC"}
</button>
</div>
{writeError && <p style={{ color: "red" }}>{writeError.message}</p>}
{isSuccess && <p style={{ color: "green" }}>Transfert confirmé !</p>}
{txHash && (
<a
href={`https://etherscan.io/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
>
Voir sur Etherscan
</a>
)}
<button onClick={() => disconnect()}>Déconnecter</button>
</div>
);
}Pour aller plus loin#
Cet article a couvert les concepts et outils essentiels pour les développeurs web qui se lancent dans Ethereum. Il y a beaucoup plus de profondeur dans chaque domaine :
- Solidity : Si vous voulez écrire des contrats, pas seulement interagir avec eux. La documentation officielle et les cours de Patrick Collins sont les meilleurs points de départ.
- Standards ERC : ERC-20 (jetons fongibles), ERC-721 (NFTs), ERC-1155 (multi-jeton), ERC-4626 (coffres tokenisés). Chacun définit une interface standard que tous les contrats de cette catégorie implémentent.
- Layer 2s : Arbitrum, Optimism, Base, zkSync. Même expérience développeur, coûts de gas réduits, hypothèses de confiance légèrement différentes. Votre code ethers.js et viem fonctionne de manière identique — il suffit de changer l'ID de chaîne et l'URL RPC.
- Account Abstraction (ERC-4337) : La prochaine évolution de l'UX des portefeuilles. Des portefeuilles smart contract qui supportent le sponsoring de gas, la récupération sociale et les transactions par lots. C'est là que le pattern « connecter le portefeuille » se dirige.
- MEV et ordonnancement des transactions : Si vous construisez de la DeFi, comprendre la Maximal Extractable Value n'est pas optionnel. La documentation Flashbots est la ressource de référence.
L'écosystème blockchain évolue vite, mais les fondamentaux de cet article — comptes, transactions, encodage ABI, appels RPC, indexation d'événements — n'ont pas changé depuis 2015 et ne changeront pas de sitôt. Apprenez-les bien et tout le reste n'est que surface d'API.