Interroger les blockchains avec SQL · Leçon 5 sur 6
Interroger les journaux et les traces EVM
10 min de lecture
Sur les chaînes EVM, la plupart des éléments qui intéressent les analystes (mouvements de jetons, swaps, approbations, prêts) sont consignés non pas dans la transaction elle-même, mais dans les journaux d’événements qu’elle émet et les appels internes qu’elle effectue. Les tables décodées couvrent les contrats populaires, mais tôt ou tard, une étude porte sur un nouveau jeton, un contrat non vérifié ou un schéma que personne n’a décodé. Cette leçon montre comment filtrer, décoder et agréger les journaux bruts et les traces en SQL, et comment mesurer le gas.
Les requêtes utilisent le schéma générique de la leçon Fondements SQL pour les tables de blockchain : logs (contract_address, topic0 à topic3, data), traces (trace_address, trace_type, call_type, from_address, to_address, value, status, tx_status), transactions et blocks.
Signatures d’événements et topic0
Un journal comporte trois parties : l’adresse du contrat qui l’a émis, jusqu’à quatre topics et un champ data (voir la leçon Transactions, reçus et journaux Ethereum bruts). Pour un événement Solidity normal, topic0 est le hachage Keccak-256 de la signature canonique de l’événement : son nom et les types de ses paramètres, sans espaces ni noms de paramètres. Les paramètres déclarés indexed sont placés dans topic1 à topic3, et les autres sont encodés selon l’ABI dans data sous forme de mots de 32 octets.
| Événement | Signature canonique | topic0 |
|---|---|---|
| Transfert ERC-20 et ERC-721 | Transfer(address,address,uint256) | 0xddf252ad…523b3ef |
| Approbation ERC-20 | Approval(address,address,uint256) | 0x8c5be1e5…8c7c3b925 |
| Transfert unique ERC-1155 | TransferSingle(address,address,address,uint256,uint256) | 0xc3d58168…aa2d0f62 |
| Dépôt d’ether enveloppé | Deposit(address,uint256) | 0xe1fffcc4…5cc9109c |
Filtrer sur topic0 permet de sélectionner un type d’événement dans tous les contrats. Ajoutez contract_address lorsque vous ciblez un jeton ou un protocole précis : n’importe qui peut déployer un contrat qui émet un événement Transfer parfaitement formé, et les jetons frauduleux font exactement cela pour faire apparaître de faux transferts dans l’historique de leurs victimes. La fiabilité d’un événement ne dépasse pas celle du contrat qui l’a émis.
Les ERC-20 et ERC-721 partagent la même signature Transfer et donc le même topic0. La différence tient à l’indexation : ERC-721 indexe l’identifiant du jeton, ses transferts comportent donc quatre topics, tandis que les transferts ERC-20 ont trois topics et placent le montant dans data. Tester topic3 IS NULL permet de les distinguer.
Dans les jeux de données réels, les topics sont souvent stockés dans un tableau plutôt que dans quatre colonnes, et l’indexation des tableaux varie selon le moteur : le premier topic est topics[OFFSET(0)] (ou SAFE_OFFSET(0)) dans BigQuery, qui commence à compter à zéro, mais topics[1] dans PostgreSQL, Trino et DuckDB, qui commencent à compter à un. Une erreur à ce niveau décale chaque topic d’une position sans générer d’erreur.
Décoder les topics et les données
Les topics et les mots de données font 32 octets, soit 64 caractères hexadécimaux après 0x. Une adresse occupe les 20 derniers octets (40 caractères), complétés par des zéros à gauche ; elle commence donc au caractère 27 de la chaîne du topic. Un mot de données k (en comptant à partir de zéro) commence au caractère 3 + 64k.
-- Décoder les événements Transfer ERC-20 d’un jeton à partir des journaux bruts
SELECT block_number, log_index, transaction_hash,
'0x' || SUBSTR(topic1, 27, 40) AS from_address,
'0x' || SUBSTR(topic2, 27, 40) AS to_address,
SUBSTR(data, 3, 64) AS amount_hex -- premier mot de 32 octets
FROM logs
WHERE contract_address = '0x7a11…0001'
AND topic0 = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
AND topic3 IS NULL -- ERC-721 ajoute un 4e topic (tokenId)
AND block_number BETWEEN 21000000 AND 21000999;
La requête filtre par contrat, événement et nombre de topics, puis extrait des sous-chaînes. Dans une ligne d’exemple, data se termine par 77359400, soit 2 000 000 000 en décimal ; avec les 6 décimales du jeton, cela représente 2 000 jetons.
La conversion du mot hexadécimal en nombre varie considérablement selon les moteurs :
| Moteur | Conversion | Limite |
|---|---|---|
| DuckDB | CAST(CONCAT('0x', h) AS HUGEINT) | Entier signé de 128 bits |
| Trino | from_base(h, 16) | BIGINT de 64 bits : échoue au-delà d’environ 9,22 × 10^18 |
| BigQuery | CAST(CONCAT('0x', h) AS INT64) pour les petites valeurs ; fonction définie par l’utilisateur au-delà | INT64 de 64 bits |
| PostgreSQL | Aucune fonction intégrée pour les grandes valeurs ; fonction définie par l’utilisateur | Dépend de la fonction |
La limite de 64 bits est le piège : 9,22 × 10^18 unités brutes ne représentent que 9,22 jetons avec 18 décimales ; les conversions qui fonctionnent sur des données de test échouent donc ou débordent avec de vrais transferts. Pour tout autre usage qu’une vérification rapide, décodez les montants à l’étape ETL ou utilisez une table décodée, et stockez le résultat dans un type décimal ou de grand nombre. Les types dynamiques (chaînes, tableaux) sont encodés sous forme de décalages et de longueurs ; mieux vaut les laisser à une bibliothèque de décodage ABI.
Certaines valeurs spéciales peuvent être vérifiées sans aucune conversion. Une approbation « illimitée » définit le montant autorisé à 2^256 − 1, soit 64 caractères f :
-- Approbations illimitées de jetons : montant autorisé = 2^256 - 1 (64 'f' hexadécimaux)
SELECT block_number, transaction_hash,
contract_address AS token,
'0x' || SUBSTR(topic1, 27, 40) AS owner,
'0x' || SUBSTR(topic2, 27, 40) AS spender
FROM logs
WHERE topic0 = '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925'
AND LOWER(data) = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'
AND block_number BETWEEN 21000000 AND 21000999;
La comparaison de chaînes évite tout calcul sur 256 bits. Dresser la liste des détenteurs qui ont accordé des approbations illimitées à un même dépensier est une étape courante pour évaluer la portée d’une campagne de vidage de portefeuilles ou d’un protocole compromis (voir la leçon Analyse des exploits et des piratages).
Transferts internes à partir des traces
L’ETH envoyé par un contrat, par exemple lors d’un retrait d’un pool ou d’un paiement effectué par un portefeuille multisignature, n’apparaît pas dans transactions.value. Il apparaît dans les traces : une ligne par cadre d’appel, produite par la réexécution de la transaction. Colonnes clés :
trace_address: position du cadre dans l’arbre d’appels. Dans ce schéma, il s’agit d’un texte :''pour l’appel de premier niveau,'0'pour son premier sous-appel,'0,1'pour le deuxième appel à l’intérieur de celui-ci. Certains jeux de données le stockent sous forme de tableau, où l’appel de premier niveau correspond à un tableau vide.trace_type:call,create(déploiement de contrat),suicide(ancien nom de selfdestruct) oureward.call_type: pour les appels,call,delegatecalloustaticcall.status(ce cadre a réussi) ettx_status(la transaction entière a réussi).
-- Transferts d’ETH internes (envoyés par des contrats) vers une adresse
SELECT block_number, transaction_hash, trace_address,
from_address, value / 1e18 AS value_eth
FROM traces
WHERE to_address = '0x5e1f…c0de'
AND value > 0
AND trace_type = 'call'
AND call_type = 'call' -- delegatecall et staticcall ne transfèrent pas d’ETH
AND trace_address <> '' -- '' correspond à l’appel de premier niveau lui-même
AND status = 1 -- ce cadre d’appel a réussi
AND tx_status = 1 -- et la transaction également
AND block_number BETWEEN 21000000 AND 21050000;
Chaque filtre élimine un faux positif connu. Un staticcall ne peut pas transférer de valeur, et un delegatecall exécute le code d’un autre contrat dans le contexte de l’appelant sans déplacer d’ETH, même si certains formats de trace lui attribuent une valeur qui n’a jamais été transférée. Exclure le cadre de premier niveau évite de compter deux fois la valeur de la transaction si vous interrogez aussi transactions. Le statut est plus important pour les traces que pour les journaux : une transaction annulée n’émet aucun journal, mais ses traces sont tout de même enregistrées, et un cadre peut être annulé à l’intérieur d’une transaction qui réussit dans son ensemble. Les effets d’un cadre ne persistent que si ce cadre et tous les cadres situés au-dessus de lui ont réussi ; exiger à la fois status et tx_status couvre les cas habituels, mais un contrat situé au milieu de l’arbre peut intercepter une annulation qui passera tout de même entre les mailles du filet. Il faut donc vérifier individuellement les transactions inhabituelles.
Pour mesurer tout l’ETH reçu par une adresse, une règle plus simple consiste à n’utiliser que les traces, y compris le cadre de premier niveau, au lieu d’additionner transactions et traces. N’oubliez pas que les cadres create accompagnés d’une valeur et les cadres selfdestruct déplacent aussi de l’ETH.
Analyses du gas
Depuis EIP-1559 (août 2021), les frais de chaque transaction se répartissent entre des frais de base brûlés et des frais prioritaires versés au producteur du bloc :
- frais payés = gas utilisé × prix effectif du gas
- montant brûlé = gas utilisé × frais de base par unité de gas (indiqués dans le bloc)
- frais prioritaires = gas utilisé × (prix effectif du gas − frais de base)
Les transactions contenant des blobs (EIP-4844) paient des frais distincts pour les blobs, soit le gas des blobs utilisé × le prix du gas des blobs. Ces frais sont également brûlés et consignés dans des champs distincts du reçu.
-- Frais quotidiens, montants brûlés et frais prioritaires, en ETH
SELECT DATE_TRUNC('day', t.block_timestamp) AS day_utc,
COUNT(*) AS txs,
SUM(t.gas_used * t.effective_gas_price) / 1e18 AS fees_eth,
SUM(t.gas_used * b.base_fee_per_gas) / 1e18 AS burned_eth,
SUM(t.gas_used * (t.effective_gas_price - b.base_fee_per_gas)) / 1e18
AS tips_eth
FROM transactions AS t -- les transactions échouées paient aussi des frais
JOIN blocks AS b ON b.number = t.block_number
WHERE t.block_timestamp >= TIMESTAMP '2024-11-01 00:00:00'
AND t.block_timestamp < TIMESTAMP '2024-11-08 00:00:00'
GROUP BY DATE_TRUNC('day', t.block_timestamp)
ORDER BY day_utc;
Contrairement aux requêtes sur les valeurs, celle-ci conserve volontairement les transactions échouées, car elles consomment du gas et le facturent. Le produit du gas par un prix en wei dépasse facilement 64 bits (10 millions de gas à 1 000 gwei correspondent à 10^19 wei), ce qui explique aussi pourquoi le schéma stocke ces colonnes sous forme de nombres décimaux. Sur un petit échantillon, une journée affiche 0,0239 ETH de frais, dont seulement 0,0030 ETH ont été brûlés : une seule transaction payant 1 000 gwei par unité de gas (des frais prioritaires de 980 gwei au-dessus des frais de base) domine le total. C’est le genre de valeur aberrante qui mérite un examen plus approfondi.
Les données sur le gas permettent de répondre à plusieurs questions analytiques : quelle adresse a financé le gas du compte (un lien fort ; voir la leçon Heuristiques du modèle de compte), quels expéditeurs paient des frais prioritaires exceptionnellement élevés (souvent des robots ou des attaquants pressés par le temps) et combien une attaque a coûté. Sur les rollups, les reçus comportent des champs supplémentaires pour les frais de données L1, dont les noms varient selon la chaîne.
Points clés pour l'examen : topic0 est le hachage Keccak-256 de la signature canonique de l’événement, les paramètres indexés figurant dans les topics et les autres dans des mots de données de 32 octets ; filtrage par adresse du contrat pour éviter les événements usurpés ; distinction entre les transferts ERC-20 et ERC-721 à l’aide du nombre de topics ; tableaux de topics indexés à partir de zéro ou de un, et extraction d’une adresse avec SUBSTR(topic, 27, 40) et des mots de données à la position 3 + 64k ; risque de débordement sur 64 bits lors de la conversion des montants ; détection des approbations illimitées ; filtrage des traces par type d’appel, adresse de trace et statut, et raison pour laquelle les transactions annulées ont des traces mais aucun journal ; formules EIP-1559 des frais, du montant brûlé et des frais prioritaires, et inclusion des transactions échouées dans les totaux des frais.