Données on-chain et SQL · Leçon 5 sur 9
Le modèle de comptes et la reconstitution des soldes
16 min de lecture
Ethereum et les nombreuses chaînes qui reprennent sa conception tiennent un solde courant pour chaque adresse, plutôt qu’un ensemble de pièces distinctes. Cela change ce qui peut être tracé, ce que signifie une « transaction » et qui est l’acteur réel : avec les comptes intelligents, l’adresse qui signe, celle qui paie le gas et celle du champ from correspondent souvent à trois parties différentes, et les confondre revient à attribuer l’activité à la mauvaise partie. Cela conditionne également une question fréquente dans les enquêtes : « combien cette adresse détenait-elle à cette date, et comment ce solde s’est-il constitué ? » Un examinateur rigoureux y répond de deux façons : en calculant le solde à partir de l’historique et en le lisant dans l’état de la chaîne, puis en expliquant tout écart.
Un état, pas des pièces
Une chaîne fondée sur un modèle de comptes stocke un état global : une correspondance entre chaque adresse de 20 octets et un enregistrement de compte. Sur Ethereum, cet enregistrement comporte quatre champs :
| Champ | Signification | Utilité pour l’analyste |
|---|---|---|
nonce | Transactions envoyées (EOA) ou contrats créés (contrat) | Comptage de l’activité, ordonnancement, vérification des adresses nouvelles |
balance | Monnaie native, en wei | Avoirs à un bloc donné |
codeHash | Empreinte du code du compte (vide pour un EOA simple) | Distinction entre EOA, contrats et EOA délégués |
storageRoot | Racine du stockage clé-valeur du compte | Emplacement des soldes de jetons et des données du contrat |
Chaque bloc engage cryptographiquement l’état résultant au moyen du champ stateRoot de son en-tête. Les soldes sont lus avec eth_getBalance(address, block), et le stockage des contrats avec eth_call (par exemple le balanceOf d’un jeton) ou eth_getStorageAt.
| Question | Chaînes UTXO | Chaînes à comptes |
|---|---|---|
| Unité de valeur | Sorties distinctes, dépensées intégralement | Un solde par adresse |
| Monnaie rendue | Une nouvelle sortie, souvent vers une nouvelle adresse | Aucune : le reliquat reste dans le solde |
| Lien entre un paiement sortant et un paiement entrant | Exact pour chaque sortie ; ambigu au sein d’une transaction à plusieurs entrées | Jamais exact : tous les dépôts se confondent |
| Réutilisation des adresses | Déconseillée | Normale ; une même adresse pendant des années |
| Protection contre le rejeu | Chaque sortie ne peut être dépensée qu’une fois | Chaque transaction doit utiliser le nonce suivant |
Lorsque 10 ETH arrivent à une adresse qui détient déjà 50 ETH, rien dans les données n’indique quels ETH sont partis ensuite. Toute attribution d’un paiement sortant à un dépôt précis est une convention de traçage choisie par l’analyste, et non un fait enregistré (la leçon 3.3 présente les méthodes).
EOA, contrats et EOA délégués
Un compte détenu en externe (EOA) est contrôlé par une clé privée et constitue le seul type de compte pouvant être à l’origine d’une transaction. Un compte de contrat possède du code et du stockage, et n’agit que lorsqu’il est appelé. eth_getCode(address, block) renvoie 0x pour un EOA et le bytecode déployé pour un contrat. Trois pièges rendent cette vérification moins simple qu’il n’y paraît :
- Temporalité. Le code n’existe qu’à partir du bloc dans lequel le contrat a été créé. Les adresses peuvent être calculées avant le déploiement (CREATE2 permet de le faire délibérément) ; une adresse sans code au bloc N peut donc être un contrat à N+1. Vérifiez le code au bloc pertinent.
- Destruction. Avant la mise à niveau Dencun (mars 2024),
SELFDESTRUCTsupprimait le code d’un contrat. Depuis EIP-6780, cette suppression n’a lieu que si l’instruction est appelée dans la transaction qui a créé le contrat ; sinon, elle se contente de transférer le solde. - Délégation. Depuis la mise à niveau Pectra (mai 2025), un EOA peut porter le désignateur de délégation EIP-7702 de 23 octets, composé de
0xef0100suivi d’une adresse de délégation. Une règle naïve du type « il a du code, donc c’est un contrat » le classe à tort : le compte reste contrôlé par sa clé privée.
Un outil de classification robuste distingue donc trois catégories (EOA, contrat, EOA délégué) à un bloc donné.
Le nonce est un atout pour les analystes. Pour un EOA, il est égal au nombre de transactions envoyées ; un nonce de 0 signifie donc que l’adresse n’a jamais rien envoyé, et les lacunes ou les pics révèlent des schémas opérationnels (leçon 4.3). Deux réserves : chaque autorisation EIP-7702 signée par un EOA incrémente également son nonce, et le nonce d’un contrat compte les créations de contrats, en commençant à 1 conformément à EIP-161. Les adresses de contrats sont dérivées du déployeur : CREATE utilise l’adresse et le nonce du déployeur ; CREATE2 utilise le déployeur, un sel et l’empreinte du code de création, ce qui explique pourquoi les liens avec le déployeur constituent des éléments probants solides.
Où se trouve l’historique
Une requête d’état répond à la question « que détenait cette adresse au bloc N ? » L’historique répond à la question « comment ce solde s’est-il constitué ? », et il est réparti entre plusieurs jeux de données :
- transactions de premier niveau : valeur envoyée, plus gas payé par l’expéditeur ;
- traces des appels internes : valeur déplacée par les contrats ;
- journaux : transferts de jetons et autres événements ;
- variations de solde qui ne sont pas des transactions : frais de priorité crédités au bénéficiaire des frais du bloc, retraits de la couche de consensus crédités directement aux adresses depuis avril 2023 (EIP-4895 : pas de gas, pas d’exécution de code, pas d’empreinte de transaction), allocations de genèse et, avant septembre 2022, récompenses de minage.
Les nœuds complets ne conservent que l’état récent ; l’état aux blocs anciens nécessite donc un nœud d’archive ou un fournisseur qui en propose un (leçon 2.1).
Transactions internes
Dans le protocole, une transaction est uniquement le message signé de premier niveau. Lorsqu’un contrat transmet des ETH pendant son exécution, il s’agit d’un appel de message : aucune empreinte, aucun reçu et aucune entrée de journal, sauf si le code en émet une. Les explorateurs les appellent « transactions internes », une appellation pratique mais trompeuse. Supposons que la transaction d’un utilisateur envoie 0 ETH à un contrat de portefeuille 0x7c3E…12, lequel appelle pendant son exécution 0xD4e5…3a avec la valeur 0xde0b6b3a7640000, soit 10^18 wei ou 1 ETH. La liste des transactions de 0xD4e5…3a ne montre rien, alors que son solde a augmenté de 1 ETH. Seule une trace (leçon 2.4) explique cette variation. Les sous-appels annulés ne déplacent rien et doivent être exclus.
Un transfert ERC-20 est encore différent : il s’agit d’une modification du stockage du contrat de jeton annoncée par un événement Transfer, et non d’une transaction interne (leçon 3.5).
Comptes intelligents : qui signe, qui paie, qui agit
Un compte intelligent est un portefeuille implémenté sous forme de contrat, avec des règles telles que plusieurs signataires, des plafonds de dépense, une récupération ou une prise en charge du gas par un tiers.
Les portefeuilles de contrat (les portefeuilles multisignatures en sont l’exemple classique) détiennent les fonds à une adresse de contrat. Les propriétaires signent hors chaîne et l’un d’eux, ou un relais, soumet la transaction ; l’EOA dans from peut donc n’être que le dernier signataire.
ERC-4337 ajoute l’abstraction de compte sans modification du protocole. Les utilisateurs signent une UserOperation (dont les champs comprennent sender, nonce, callData, les limites de gas, paymaster et signature) et l’envoient dans un mempool distinct. Un agrégateur en regroupe plusieurs dans une transaction qui appelle handleOps sur un contrat EntryPoint unique, lequel valide et exécute chaque opération et rémunère l’agrégateur à partir du dépôt du compte ou de celui d’un paymaster. Pour chaque opération, il émet :
UserOperationEvent(bytes32 indexed userOpHash, address indexed sender, address indexed paymaster, uint256 nonce, bool success, uint256 actualGasCost, uint256 actualGasUsed)
Le champ from de la transaction correspond à l’agrégateur, une transaction peut contenir des opérations d’utilisateurs sans lien entre eux, le reçu couvre l’ensemble du lot, et l’acteur réel est le sender de l’événement. Avec le schéma générique de la leçon 2.7 :
-- Une ligne par UserOperation ERC-4337, avec agrégateur, compte intelligent et paymaster
SELECT l.block_number,
l.transaction_hash,
t.from_address AS bundler,
'0x' || SUBSTR(l.topic2, 27, 40) AS smart_account, -- sender indexé
'0x' || SUBSTR(l.topic3, 27, 40) AS paymaster -- adresse zéro = aucun
FROM logs AS l
JOIN transactions AS t ON t.hash = l.transaction_hash
WHERE l.topic0 = '0x49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f'
AND l.contract_address IN (SELECT address FROM entrypoint_contracts);
topic0 est l’empreinte Keccak-256 de la signature de l’événement, et SUBSTR(topic, 27, 40) extrait une adresse indexée de son topic de 32 octets complété par des zéros (leçon 2.9) ; certains moteurs nomment cette fonction SUBSTRING, et les jeux de données qui stockent les topics en binaire nécessitent à la place une extraction par octets. Restreindre la requête aux adresses EntryPoint connues (une par version, conservées dans une petite table du dossier) évite de retenir des événements similaires provenant d’autres contrats.
EIP-7702 permet à un EOA existant d’adopter le comportement d’un compte intelligent. Une transaction de type 4 contient des autorisations [chain_id, address, nonce, y_parity, r, s] ; chacune définit le code du signataire comme étant le désignateur de délégation, qui persiste jusqu’à son remplacement ou sa suppression, et une autorisation dont le chain_id vaut 0 est valable sur toutes les chaînes. L’adresse conserve son historique et sa clé, mais peut désormais regrouper des appels ou faire payer son gas par d’autres. Les cibles de délégation constituent elles aussi des éléments probants : un tableau de bord public de données sur chaîne, publié par la société de trading Wintermute en juin 2025, a révélé que plus de 80 % des premières délégations pointaient vers des copies d’un même contrat « siphonneur » qui vide les portefeuilles dont les clés ont fuité.
| Schéma | Qui autorise | from de la transaction (paie le gas) | Où trouver l’acteur réel |
|---|---|---|---|
| Transaction EOA simple | Clé de l’EOA | Le même EOA | from |
| Portefeuille de contrat | Signatures des propriétaires dans les données d’appel | Un propriétaire ou un relais | to (le portefeuille) et ses événements |
| Méta-transaction (ERC-2771) | Signature de l’utilisateur | Un relais via un transmetteur de confiance | Données d’appel décodées |
| ERC-4337 | Règles de validation du compte intelligent | Agrégateur, remboursé par le compte ou le paymaster | UserOperationEvent.sender |
| EIP-7702 avec prise en charge du gas par un tiers | Autorisation de l’EOA et charge utile signée | Un tiers prenant en charge le gas | L’EOA délégué appelé |
N’assimilez jamais le payeur du gas à l’acteur avant d’avoir identifié le schéma. Le financement du gas reste un élément probant utile, mais lorsqu’il est pris en charge par un tiers, il peut désigner un service plutôt qu’un propriétaire.
Deux voies pour obtenir un solde
| Voie | Méthode | Points forts | Points faibles |
|---|---|---|---|
| Calcul à partir de l’historique | Additionner tous les crédits et débits jusqu’à un bloc | Explique chaque mouvement ; fonctionne dans un entrepôt de données | Risque d’omettre une catégorie de mouvements |
| Lecture de l’état | Demander à un nœud le solde à un bloc donné | Fait autorité pour cette chaîne et ce bloc | Aucune explication ; les blocs anciens nécessitent un état archivé |
Le montant calculé figure dans le rapport parce qu’il retrace les faits ; le montant lu dans l’état sert de contrôle. Lorsqu’ils concordent, l’historique est complet. Lorsqu’ils diffèrent, il manque quelque chose, et l’ampleur de l’écart indique généralement de quoi il s’agit.
Soldes UTXO
Sur une chaîne UTXO, le solde d’un ensemble de scripts à la hauteur H est la somme des sorties qui leur sont destinées, créées à la hauteur H ou avant, et non dépensées à la hauteur H ou avant (leçon 2.3). Pour une adresse :
| Point de sortie | Créé à la hauteur | Montant (BTC) | Dépensé à la hauteur |
|---|---|---|---|
4f3a…c21e:1 | 880 100 | 0,4000 | 880 900 |
a91c…3b07:0 | 880 450 | 1,2500 | non dépensé |
77d2…e4a0:3 | 881 020 | 0,0800 | 885 300 |
c0b5…91f2:0 | 884 700 | 0,3000 | non dépensé |
À la hauteur 882 000, les sorties non dépensées sont a91c…3b07:0 et 77d2…e4a0:3 : 1,33 BTC. À la hauteur 886 000, il s’agit de a91c…3b07:0 et c0b5…91f2:0 : 1,55 BTC. Le montant reçu (2,03) moins le montant envoyé (0,48) donne le même résultat de 1,55, un contrôle interne utile. Un portefeuille n’est pas une adresse : si les descripteurs de sortie ou une clé publique étendue sont connus (par exemple, fournis par le propriétaire dans un litige), dérivez toutes les adresses, y compris celles de monnaie rendue, avant de faire la somme. Les transactions du mempool ne font pas partie des soldes ; les sorties coinbase datant de moins de 100 blocs existent, mais ne peuvent pas encore être dépensées. La commande scantxoutset de Bitcoin Core ne voit que l’ensemble UTXO actuel ; les vérifications historiques nécessitent donc un indexeur ou un second jeu de données constitué indépendamment.
Soldes natifs sur les chaînes à comptes
balance(N) = valeur reçue dans les transactions de premier niveau réussies
+ valeur reçue dans les appels internes réussis (traces)
+ retraits de la couche de consensus et récompenses du bénéficiaire des frais
- valeur envoyée dans les transactions de premier niveau et les appels internes réussis
- frais de gas payés pour chaque transaction envoyée, réussie ou échouée
Les allocations de genèse et les récompenses de minage antérieures à The Merge ajoutent des termes pour les adresses plus anciennes. Voici un exemple de calcul pour 0x6F2a…c19E au bloc N :
| Élément | Source | Montant (ETH) |
|---|---|---|
| Dépôt provenant d’une plateforme d’échange | Transaction | +5,000000 |
| Versement provenant d’un contrat | Trace (appel interne) | +1,000000 |
| Retraits de validateur | Liste des retraits du bloc | +0,032000 |
| Paiement envoyé | Transaction | −2,500000 |
| Gas de ce paiement : 21 000 × 20 gwei | Reçu | −0,000420 |
| Transaction échouée : valeur non envoyée | status du reçu = 0 | 0 |
| Gas de la transaction échouée : 70 000 × 30 gwei | Reçu | −0,002100 |
| Solde calculé | 3,529480 |
Si eth_getBalance(0x6F2a…c19E, N) renvoie 3,529480 ETH, l’historique est complet. Les erreurs laissent des indices : ignorer les traces sous-estime le solde d’un montant exactement égal au crédit interne (1,0) ; comptabiliser la valeur de la transaction échouée sous-estime le solde de cette valeur ; oublier son gas surestime le solde de 0,0021 ; les retraits manquants laissent un écart constitué de petits montants réguliers. Le coût du gas correspond au gas utilisé × prix effectif du gas indiqué dans le reçu, jamais à la limite de gas, auxquels s’ajoutent les frais de blobs pour les transactions de type 3.
Soldes de jetons : événements et balanceOf
Les soldes de jetons se trouvent dans le stockage du contrat de jeton. Ils peuvent être calculés en additionnant les événements Transfer ou lus avec balanceOf(holder) via eth_call au bloc N :
-- Soldes de jetons au bloc 21 000 000, calculés à partir des événements Transfer
WITH moves AS (
SELECT to_address AS holder, amount_raw AS delta
FROM token_transfers
WHERE token_address = '0x5d3a…7c10' AND block_number <= 21000000
UNION ALL
SELECT from_address AS holder, -amount_raw AS delta
FROM token_transfers
WHERE token_address = '0x5d3a…7c10' AND block_number <= 21000000
)
SELECT holder, SUM(delta) / POWER(10, 6) AS balance -- le jeton a 6 décimales
FROM moves
GROUP BY holder
HAVING SUM(delta) <> 0;
Chaque transfert devient un crédit et un débit. Les créations de jetons sont des transferts depuis l’adresse zéro ; l’adresse zéro se retrouve donc avec un solde négatif égal à tout ce qui a été créé moins tout ce qui a été détruit en lui étant envoyé : un contrôle intégré de l’offre. Conservez les entiers bruts dans un type décimal de grande capacité jusqu’à la division finale.
Les événements et balanceOf divergent pour des raisons connues : les jetons à réajustement de solde modifient tous les soldes au moyen d’un multiplicateur global, sans événement pour chaque détenteur ; les jetons portant intérêt calculent balanceOf à partir d’un indice croissant ; les jetons à frais de transfert créditent au destinataire un montant inférieur à celui envoyé par l’expéditeur, et certains émettent le montant brut ; enfin, certains contrats anciens ou contrôlés par l’émetteur créent des jetons sans événement Transfer ou émettent un événement personnalisé lors du gel et de la destruction. Pour ces jetons, l’état fait foi et les événements ne retracent que les transferts explicites.
Instantanés et rapprochement
Un solde « au 31 mars » est ambigu tant qu’il n’est pas rattaché à un bloc : prenez le dernier bloc dont l’horodatage est antérieur ou égal à l’heure limite, en UTC, et consignez sa hauteur et son empreinte. Dans le cadre d’EIP-1898, eth_getBalance et eth_call acceptent une empreinte de bloc, ce qui garantit que la réponse provient de ce bloc précis, même si une réorganisation remplace ultérieurement le bloc à cette hauteur. Pour les instantanés multichaînes, chaque chaîne possède son propre bloc pour le même instant (les blocs Bitcoin d’environ 10 minutes offrent une granularité moins fine que les créneaux de 12 secondes d’Ethereum, et un bloc de rollup peut refléter des dépôts que sa L1 vient tout juste d’inclure) ; indiquez le bloc choisi pour chacune.
Une reconstitution n’est pas terminée tant qu’elle n’a pas été vérifiée : comparez les soldes calculés à l’état (pour toutes les adresses du périmètre, ou pour un échantillon à plusieurs blocs) ; vérifiez que la somme des soldes de jetons calculés, hors adresse zéro, est égale à totalSupply() au même bloc ; considérez tout solde courant négatif comme le signe d’un crédit manquant ; vérifiez les bornes (zéro avant la première activité, et le solde laissé par un transfert intégral connu) ; et fixez une tolérance à l’avance, en documentant la cause de chaque écart résiduel.
| Symptôme | Cause probable |
|---|---|
| Solde calculé trop faible, avec des écarts correspondant aux versements de contrats | Appels internes manquants dans les traces |
| Solde calculé trop élevé de petits montants | Gas des transactions échouées non déduit |
| Petits crédits réguliers inexpliqués | Retraits de la couche de consensus ou récompenses du bénéficiaire des frais |
Les soldes des détenteurs de jetons s’écartent chaque jour de balanceOf | Jeton à réajustement de solde ou portant intérêt |
La somme des soldes diffère de totalSupply() | Créations ou destructions sans événements Transfer |
Points clés pour l’examen : attendez-vous à des questions de mise en situation sur l’identité de l’acteur et sur les raisons d’un écart de solde : les quatre champs d’un compte et la signification du nonce ; pourquoi la mise en commun des soldes fait du traçage pièce par pièce une convention ; la classification des EOA, des contrats et des EOA délégués EIP-7702 à un bloc donné (0xef0100, EIP-6780) ; les transactions internes comme appels de message visibles uniquement dans les traces ; les retraits et les récompenses de frais comme crédits ne constituant pas des transactions ; les rôles ERC-4337 etUserOperationEvent.sender; les soldes calculés et les soldes lus dans l’état ; les composantes du solde natif, notamment le gas des transactions échouées ; les soldes de jetons à partir des événements et le contrôle de l’offre à l’aide de l’adresse zéro ; les jetons pour lesquels la somme des événements ne suffit pas ; le rattachement des instantanés à une hauteur et à une empreinte (EIP-1898) ; ce que suggère chaque type d’écart.
Questions d’entraînement
Vérifiez votre compréhension. Ces questions ne comptent pas pour l’examen.
Connectez-vous pour enregistrer votre progression et reprendre là où vous vous êtes arrêté. C’est gratuit. Se connecter