Blockchains mit SQL abfragen · Lektion 5 von 6
EVM-Logs und -Traces abfragen
9 Min. Lesezeit
Auf EVM-Chains wird das meiste, was für Analysten relevant ist (Tokenbewegungen, Swaps, Genehmigungen, Kredite), nicht in der Transaktion selbst aufgezeichnet, sondern in den Ereignis-Logs, die sie ausgibt, und den internen Aufrufen, die sie ausführt. Dekodierte Tabellen decken beliebte Contracts ab, doch früher oder später geht es in einem Fall um einen neuen Token, einen nicht verifizierten Contract oder ein Muster, das noch niemand dekodiert hat. Diese Lektion zeigt, wie sich rohe Logs und Traces in SQL filtern, dekodieren und aggregieren lassen und wie man den Gasverbrauch misst.
Die Abfragen verwenden das generische Schema aus der Lektion SQL-Grundlagen für Blockchain-Tabellen: logs (contract_address, topic0 bis topic3, data), traces (trace_address, trace_type, call_type, from_address, to_address, value, status, tx_status), transactions und blocks.
Ereignissignaturen und topic0
Ein Log hat drei Bestandteile: die Contract-Adresse, die es ausgegeben hat, bis zu vier Topics und ein data-Feld (siehe die Lektion Rohe Ethereum-Transaktionen, Receipts und Logs). Bei einem normalen Solidity-Ereignis ist topic0 der Keccak-256-Hash der kanonischen Signatur des Ereignisses: der Name und die Parametertypen ohne Leerzeichen oder Parameternamen. Als indexed deklarierte Parameter kommen in topic1 bis topic3; die übrigen werden als 32-Byte-Wörter ABI-kodiert in data abgelegt.
| Ereignis | Kanonische Signatur | topic0 |
|---|---|---|
| ERC-20- und ERC-721-Transfer | Transfer(address,address,uint256) | 0xddf252ad…523b3ef |
| ERC-20-Genehmigung | Approval(address,address,uint256) | 0x8c5be1e5…8c7c3b925 |
| ERC-1155-Einzeltransfer | TransferSingle(address,address,address,uint256,uint256) | 0xc3d58168…aa2d0f62 |
| Einzahlung von Wrapped Ether | Deposit(address,uint256) | 0xe1fffcc4…5cc9109c |
Ein Filter auf topic0 wählt eine Ereignisart über alle Contracts hinweg aus. Füge contract_address hinzu, wenn du einen bestimmten Token oder ein bestimmtes Protokoll meinst: Jeder kann einen Contract bereitstellen, der ein vollkommen korrekt formatiertes Transfer-Ereignis ausgibt, und genau das tun Scam-Token, um gefälschte Transfers in den Transaktionsverläufen von Opfern zu platzieren. Ein Ereignis ist nur so vertrauenswürdig wie der Contract, der es ausgegeben hat.
ERC-20 und ERC-721 verwenden dieselbe Transfer-Signatur und haben daher dasselbe topic0. Der Unterschied liegt in der Indizierung: ERC-721 indiziert die Token-ID, sodass seine Transfers vier Topics haben, während ERC-20-Transfers drei Topics haben und den Betrag in data enthalten. Mit topic3 IS NULL lassen sie sich voneinander unterscheiden.
Reale Datensätze speichern Topics oft als Array statt in vier Spalten, und die Array-Indizierung unterscheidet sich je nach Engine: Das erste Topic ist in BigQuery topics[OFFSET(0)] (oder SAFE_OFFSET(0)), das bei null zu zählen beginnt, in PostgreSQL, Trino und DuckDB hingegen topics[1], da diese bei eins zu zählen beginnen. Wird dies falsch gemacht, verschiebt sich jedes Topic um eine Position, ohne dass ein Fehler ausgelöst wird.
Topics und Daten dekodieren
Topics und Datenwörter sind 32 Byte groß und werden nach 0x als 64 Hex-Zeichen geschrieben. Eine Adresse nimmt die letzten 20 Byte (40 Zeichen) ein und wird links mit Nullen aufgefüllt. Sie beginnt daher beim 27. Zeichen der Topic-Zeichenfolge. Ein Datenwort k (gezählt ab null) beginnt bei Zeichen 3 + 64k.
-- ERC-20-Transfer-Ereignisse eines Tokens aus rohen Logs dekodieren
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 -- erstes 32-Byte-Wort
FROM logs
WHERE contract_address = '0x7a11…0001'
AND topic0 = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
AND topic3 IS NULL -- ERC-721 fügt ein 4. Topic (tokenId) hinzu
AND block_number BETWEEN 21000000 AND 21000999;
Die Abfrage filtert nach Contract, Ereignis und Topic-Anzahl und schneidet dann die Zeichenfolgen aus. In einer Beispieldatenzeile endet data mit 77359400, was dezimal 2.000.000.000 entspricht; bei 6 Dezimalstellen des Tokens sind das 2.000 Token.
Die Umwandlung des Hex-Worts in eine Zahl unterscheidet sich zwischen Engines am stärksten:
| Engine | Umwandlung | Grenze |
|---|---|---|
| DuckDB | CAST(CONCAT('0x', h) AS HUGEINT) | Vorzeichenbehaftete 128-Bit-Ganzzahl |
| Trino | from_base(h, 16) | 64-Bit-BIGINT: schlägt oberhalb von etwa 9,22 × 10^18 fehl |
| BigQuery | CAST(CONCAT('0x', h) AS INT64) für kleine Werte; darüber hinaus eine benutzerdefinierte Funktion | 64-Bit-INT64 |
| PostgreSQL | Keine integrierte Funktion für große Werte; eine benutzerdefinierte Funktion | Abhängig von der Funktion |
Die 64-Bit-Grenze ist die Falle: 9,22 × 10^18 Basiseinheiten sind bei 18 Dezimalstellen nur 9,22 Token. Daher schlagen Umwandlungen, die mit Testdaten funktionieren, bei realen Transfers fehl oder laufen über. Für alles, was über eine schnelle Prüfung hinausgeht, solltest du Beträge im ETL-Schritt dekodieren oder eine dekodierte Tabelle verwenden und das Ergebnis als Dezimal- oder Big-Number-Typ speichern. Dynamische Typen (Zeichenfolgen, Arrays) werden als Offsets und Längen kodiert und sollten am besten einer ABI-Dekodierungsbibliothek überlassen werden.
Einige spezielle Werte lassen sich ganz ohne Umwandlung prüfen. Bei einer „unbegrenzten“ Genehmigung wird die Allowance auf 2^256 − 1 gesetzt, was 64 f-Zeichen entspricht:
-- Unbegrenzte Token-Genehmigungen: Allowance = 2^256 - 1 (64 Hex-„f“)
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;
Der Zeichenfolgenvergleich vermeidet jede 256-Bit-Arithmetik. Listen von Eigentümern, die einem Spender unbegrenzte Genehmigungen erteilt haben, sind ein gängiger Schritt, um die Reichweite einer Wallet-Drainer-Kampagne oder eines kompromittierten Protokolls einzuschätzen (siehe die Lektion Exploits und Hacks analysieren).
Interne Transfers aus Traces
Von einem Contract gesendetes ETH, zum Beispiel eine Auszahlung aus einem Pool oder eine Ausschüttung von einer Multisig, erscheint nicht in transactions.value. Es erscheint in Traces: eine Zeile pro Aufrufrahmen, erzeugt durch erneute Ausführung der Transaktion. Wichtige Spalten:
trace_address: die Position des Rahmens im Aufrufbaum. In diesem Schema ist der Wert Text:''für den Aufruf der obersten Ebene,'0'für dessen ersten Unteraufruf,'0,1'für den zweiten Aufruf innerhalb dieses Unteraufrufs. Manche Datensätze speichern den Wert als Array; dann hat der Aufruf der obersten Ebene ein leeres Array.trace_type:call,create(Contract-Bereitstellung),suicide(historischer Name für selfdestruct) oderreward.call_type: bei Aufrufencall,delegatecalloderstaticcall.status(dieser Rahmen war erfolgreich) undtx_status(die gesamte Transaktion war erfolgreich).
-- Interne ETH-Transfers (von Contracts gesendet) an eine 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 und staticcall übertragen kein ETH
AND trace_address <> '' -- '' ist der Aufruf der obersten Ebene selbst
AND status = 1 -- dieser Aufrufrahmen war erfolgreich
AND tx_status = 1 -- und ebenso die Transaktion
AND block_number BETWEEN 21000000 AND 21050000;
Jeder Filter entfernt einen bekannten falschen Treffer. Ein staticcall kann keinen Wert übertragen, und ein delegatecall führt den Code eines anderen Contracts im Kontext des aufrufenden Contracts aus, ohne ETH zu bewegen, auch wenn manche Trace-Formate dafür einen Wert anzeigen, der nie übertragen wurde. Durch den Ausschluss des Rahmens der obersten Ebene wird vermieden, dass der Wert der Transaktion doppelt gezählt wird, wenn du auch transactions abfragst. Der Status ist bei Traces wichtiger als bei Logs: Eine fehlgeschlagene Transaktion gibt überhaupt keine Logs aus, ihre Traces werden jedoch weiterhin aufgezeichnet, und ein Rahmen kann innerhalb einer insgesamt erfolgreichen Transaktion fehlschlagen. Die Auswirkungen eines Rahmens bleiben nur bestehen, wenn er selbst und jeder übergeordnete Rahmen erfolgreich waren. Sowohl status als auch tx_status vorauszusetzen, deckt die üblichen Fälle ab; ein Fehlschlag, der von einem Contract mitten im Baum abgefangen wird, kann dennoch durchrutschen. Prüfe ungewöhnliche Transaktionen daher einzeln.
Um alle ETH zu messen, die eine Adresse erhalten hat, gilt eine einfachere Regel: Verwende ausschließlich Traces, einschließlich des Rahmens der obersten Ebene, anstatt Transaktionen und Traces zusammenzurechnen. Denke daran, dass auch create-Rahmen mit Wert und selfdestruct-Rahmen ETH bewegen.
Gas-Analysen
Seit EIP-1559 (August 2021) teilt sich die Gebühr jeder Transaktion in eine verbrannte Basisgebühr und eine an den Blockproduzenten gezahlte Prioritätsgebühr auf:
- gezahlte Gebühr = verbrauchtes Gas × effektiver Gaspreis
- verbrannter Betrag = verbrauchtes Gas × Basisgebühr pro Gas (aus dem Block)
- Prioritätsgebühr = verbrauchtes Gas × (effektiver Gaspreis − Basisgebühr)
Transaktionen mit Blobs (EIP-4844) zahlen eine separate Blob-Gebühr, also verbrauchtes Blob-Gas × Blob-Gaspreis. Auch diese Gebühr wird verbrannt und in eigenen Receipt-Feldern aufgezeichnet.
-- Tägliche Gebühren, verbrannte Beträge und Prioritätsgebühren in 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 -- auch fehlgeschlagene Transaktionen zahlen Gebühren
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;
Im Gegensatz zu Abfragen von Werten schließt diese Abfrage fehlgeschlagene Transaktionen bewusst ein, da sie Gas verbrauchen und dafür bezahlen. Das Multiplizieren von Gas mit einem Preis in Wei überschreitet leicht 64 Bit (10 Millionen Gas bei 1.000 Gwei sind 10^19 Wei). Auch deshalb speichert das Schema diese Spalten als Dezimalzahlen. In einer kleinen Stichprobe zeigt ein Tag Gebühren von 0,0239 ETH, von denen nur 0,0030 ETH verbrannt wurden: Eine einzelne Transaktion, die 1.000 Gwei pro Gaseinheit zahlt (eine Prioritätsgebühr von 980 Gwei über der Basisgebühr), dominiert den Gesamtbetrag – ein Ausreißer, den man sich genauer ansehen sollte.
Gas-Daten unterstützen mehrere Analysefragen: Welche Adresse hat das Gas für das Konto finanziert (eine starke Verbindung, siehe die Lektion Heuristiken zum Account-Modell)? Welche Absender zahlen ungewöhnlich hohe Prioritätsgebühren (oft Bots oder Angreifer, die unter Zeitdruck handeln)? Und was hat es gekostet, einen Angriff durchzuführen? Bei Rollups enthalten Receipts zusätzliche Felder für die L1-Datengebühr, deren Bezeichnungen sich je nach Chain unterscheiden.
Prüfungsschwerpunkt: topic0 als Keccak-256-Hash der kanonischen Ereignissignatur, mit indizierten Parametern in Topics und den übrigen Parametern in 32-Byte-Datenwörtern; Filtern nach Contract-Adresse, um gefälschte Ereignisse zu vermeiden; Unterscheidung von ERC-20- und ERC-721-Transfers anhand der Topic-Anzahl; nullbasierte und einsbasierte Topic-Arrays sowie das Extrahieren einer Adresse mit SUBSTR(topic, 27, 40) und von Datenwörtern ab 3 + 64k; die 64-Bit-Überlauffalle bei der Umwandlung von Beträgen; Erkennen unbegrenzter Genehmigungen; Filtern von Traces nach Aufruftyp, Trace-Adresse und Status sowie der Grund, warum fehlgeschlagene Transaktionen Traces, aber keine Logs haben; die EIP-1559-Formeln für Gebühren, verbrannte Beträge und Prioritätsgebühren sowie das Einbeziehen fehlgeschlagener Transaktionen in Gebührensummen.