Token-, NFT- und Smart-Contract-Flüsse · Lektion 1 von 6
Token-Transfers lesen
8 Min. Lesezeit
Auf kontobasierten Chains bewegt sich der Großteil des Werts, den Analysten verfolgen, als Token und nicht als native Coin. Ein Token-Guthaben ist lediglich eine Zahl innerhalb eines Vertrags, und der einzige praktische Nachweis seiner Änderungen ist das Ereignisprotokoll, das der Vertrag auszugeben beschließt. Diese Protokolle korrekt zu lesen und zu wissen, wann sie nicht die ganze Geschichte erzählen, macht den Unterschied zwischen einer belastbaren Flussanalyse und einer, die um den Faktor einer Billion danebenliegt.
Token sind Vertragszustand, Transfers sind Ereignisse
Ein ERC-20-Token (EIP-20) ist ein Vertrag, der eine Zuordnung von Adressen zu Guthaben speichert. Wenn jemand „USDC sendet“, ist die Transaktion überhaupt nicht an den Empfänger gerichtet: Ihr to-Feld ist die Token-Vertragsadresse (oder ein Router, der den Vertrag aufruft), ihr nativer value ist in der Regel 0, und der Empfänger erscheint nur innerhalb der Calldata und der Protokolle. Deshalb können der Tab „Transaktionen“ und der Tab „Token-Transfers“ eines Explorers für dieselbe Adresse völlig unterschiedlich aussehen.
Der Standard verlangt, dass der Vertrag bei jeder Änderung von Guthaben ein Ereignis ausgibt:
event Transfer(address indexed _from, address indexed _to, uint256 _value)
Jedes Protokoll enthält die address des ausgebenden Vertrags, bis zu vier Topics und ein data-Feld. Das erste Topic, topic0, ist der keccak-256-Hash der kanonischen Ereignissignatur Transfer(address,address,uint256): keine Parameternamen, keine Leerzeichen, kein Schlüsselwort indexed. Jeder ERC-20-Transfer auf jeder EVM-Chain hat daher dasselbe topic0. Als indexed markierte Parameter werden zu den folgenden Topics, die jeweils links mit Nullen auf 32 Bytes aufgefüllt werden; die übrigen werden in data ABI-kodiert. Hier ist ein Rohprotokoll, wie es eth_getLogs zurückgibt (Kennungen gekürzt):
{
"address": "0x7d1f…c3a0",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x000000000000000000000000a1b2c3d4…9f0e",
"0x000000000000000000000000b0b5e6f7…41aa"
],
"data": "0x0000000000000000000000000000000000000000000000000000000077359400",
"blockNumber": "0x1406f40",
"transactionHash": "0x4f3a…c21e",
"logIndex": "0x57"
}
Schrittweise dekodiert: topic0 identifiziert das Ereignis; topic1 ist _from (entfernen Sie die 24 führenden Nullzeichen, um die 20 Byte lange Adresse zu erhalten); topic2 ist _to; das Datenwort ist _value, 0x77359400 = 2.000.000.000 Basiseinheiten. Der Block ist 21.000.000, und dies ist Protokoll Nummer 87 in diesem Block. Der dekodierte Datensatz sieht so aus:
{
"event": "Transfer",
"token_contract": "0x7d1f…c3a0",
"from": "0xa1b2c3d4…9f0e",
"to": "0xb0b5e6f7…41aa",
"value_raw": "2000000000",
"decimals": 6,
"value": "2000.000000",
"block_number": 21000000,
"tx_hash": "0x4f3a…c21e",
"log_index": 87
}
Zwei Hinweise. Erstens ist die Identität des Tokens die address des Protokolls, niemals sein Name oder Symbol: Jeder kann einen Vertrag namens „USDC“ bereitstellen, der vollkommen korrekt formatierte Transfer-Ereignisse ausgibt. Zweitens hat ein ERC-721-NFT-Transfer dasselbe topic0, weil seine Signatur ebenfalls Transfer(address,address,uint256) lautet; der Unterschied besteht darin, dass die Token-ID als indexed markiert ist, sodass das Protokoll vier Topics und leere Daten enthält (siehe die Lektion NFTs und Multi-Token-Standards). Filtern Sie immer sowohl nach der Anzahl der Topics als auch nach topic0.
Ereignisse werden durch den Code des Vertrags selbst geschrieben. Ein bösartiger Vertrag kann Transfer-Ereignisse ausgeben, denen keine Änderung des Guthabens entspricht, und Address Poisoning-Kampagnen machen sich das zunutze: Sie erzeugen Transfers mit Wert null oder gefälschte Transfers „von“ einem Opfer zu einer ähnlich aussehenden Adresse, damit diese in der Historie des Opfers erscheint und versehentlich kopiert wird. Transfers mit Wert null und Transfers nicht gelisteter Token sollten vor der Analyse herausgefiltert oder gekennzeichnet werden.
Dezimalstellen und Einheiten
Das Protokoll speichert eine Ganzzahl in Basiseinheiten. Der decimals-Wert, der sie in menschenlesbare Einheiten umrechnet, ist nicht im Ereignis enthalten; er stammt aus der View-Funktion decimals() des Vertrags, die Sie einmal pro Vertrag auslesen und samt Quelle speichern sollten.
| Token (Ethereum-Mainnet) | Dezimalstellen | Rohwert für 1 Einheit |
|---|---|---|
| USDC, USDT | 6 | 1.000.000 |
| WBTC | 8 | 100.000.000 |
| DAI, WETH, die meisten Token | 18 | 1.000.000.000.000.000.000 |
Die Anwendung von 18 Dezimalstellen auf einen Token mit 6 Dezimalstellen unterschätzt Beträge um den Faktor 10^12; umgekehrt werden sie um denselben Faktor überschätzt. Derselbe Vermögenswert kann auf verschiedenen Chains auch unterschiedliche Dezimalstellen haben, weil gebrückte und neu ausgegebene Versionen separate Verträge sind. Rohwerte sind 256-Bit-Ganzzahlen (bis zu 78 Ziffern) und liegen damit weit jenseits von 64-Bit-Ganzzahlen und Gleitkommazahlen mit doppelter Genauigkeit. Speichern Sie sie daher als Zeichenketten oder hochpräzise Dezimalzahlen und dividieren Sie erst bei der Darstellung (siehe die Lektion Datenqualität, Normalisierung und Herkunftsnachweis).
Mints, Burns und Angebot
EIP-20 legt fest, dass ein Vertrag, der Token erstellt, ein Transfer-Ereignis mit _from auf die Nulladresse ausgeben sollte. Die meisten Implementierungen folgen der spiegelbildlichen Konvention für Burns und geben ein Transfer-Ereignis mit _to auf die Nulladresse aus. Token an eine „tote“ Adresse wie 0x…dEaD zu senden, ist kein Burn: Das Guthaben besteht weiterhin und das Gesamtangebot ändert sich nicht, selbst wenn niemand den Schlüssel besitzt.
Nicht jeder Token hält sich an diese Konvention. Einige geben anstelle von oder zusätzlich zu Transfer eigene Mint- und Burn-Ereignisse aus. WETH (der ursprüngliche WETH9-Vertrag) gibt beim Wrapping von ETH Deposit und beim Unwrapping Withdrawal aus, jedoch kein Transfer-Ereignis. Daher ist ein WETH-Guthaben, das allein anhand von Transfer-Protokollen rekonstruiert wird, für jede Adresse, die Wrapped ETH ein- oder auspackt, falsch. Eine einfache Abstimmung des Angebots deckt solche Fälle auf:
-- Nettoangebot aus Transfer-Ereignissen im Vergleich zu totalSupply(), gelesen im selben Block
SELECT
SUM(CASE WHEN from_address = '0x0000000000000000000000000000000000000000'
THEN value_raw ELSE 0 END)
- SUM(CASE WHEN to_address = '0x0000000000000000000000000000000000000000'
THEN value_raw ELSE 0 END) AS supply_from_events
FROM token_transfers
WHERE token_contract = '0x7d1f…c3a0'
AND block_number <= 21000000;
Die Abfrage summiert Mints minus Burns bis einschließlich Block 21.000.000 in einer dekodierten token_transfers-Tabelle. Vergleichen Sie das Ergebnis mit totalSupply(), das in diesem Block aufgerufen wird; eine Abweichung bedeutet, dass der Vertrag Mints oder Burns über Ereignisse durchführt, die Ihr Decoder nicht liest.
Approvals und transferFrom
ERC-20 ermöglicht es einem Eigentümer, einem Spender mit approve(spender, amount) eine Berechtigung zu erteilen. Dabei wird Approval(address indexed owner, address indexed spender, uint256 value) ausgegeben. Der Spender, typischerweise ein DEX-Router oder ein Lending-Pool, ruft später transferFrom(owner, recipient, amount) auf. Im daraus resultierenden Transfer-Ereignis ist from der Eigentümer, dessen Token bewegt wurden, und nicht das Konto, das die Transaktion gesendet hat. Analysten, die „Transaktionssender“ mit „Mittelherkunft“ gleichsetzen, ordnen jeden über einen Router vermittelten Handel falsch zu.
Approvals sind bei Untersuchungen relevant:
- Unbegrenzte Approvals setzen den Wert auf 2^256 − 1 (alle 64 Hexadezimalziffern sind
f). Ein Spender mit einer solchen Approval kann jederzeit das gesamte Guthaben des Eigentümers bewegen. - Approval-Drainer bringen ein Opfer dazu, einem von einem Angreifer kontrollierten Spender eine Approval zu erteilen. Mit permit (EIP-2612) signiert das Opfer lediglich eine Off-Chain-Nachricht; der Angreifer reicht sie ein, bezahlt das Gas, und das Approval-Ereignis erscheint in einer Transaktion, die das Opfer nie gesendet hat.
- Approvals verknüpfen Adressen, was für das Clustering nützlich ist (siehe die Lektion Heuristiken für das Kontomodell).
Allowances lassen sich nicht immer anhand von Ereignissen rekonstruieren. Wenn ein Allowance über transferFrom verwendet wird, verringert es sich. Doch nicht jede Implementierung gibt dabei ein neues Approval-Ereignis aus; OpenZeppelins weit verbreitete Bibliothek hat dies ab Version 5.0 eingestellt. Rufen Sie für das aktuelle Allowance allowance(owner, spender) im betreffenden Block auf.
Token, die das einfache Modell durchbrechen
Die einfache Regel „Guthaben = Summe eingehender minus ausgehender Transfer-Werte“ versagt bei mehreren verbreiteten Designs.
| Design | Was geschieht | Was zu tun ist |
|---|---|---|
| Gebühren bei Transfers | Bei einem Transfer von 100 kommen beispielsweise 98 an; 2 gehen an eine Gebühren-Wallet oder werden verbrannt, häufig über ein zweites Transfer-Ereignis | Verwenden Sie den Betrag, der laut Transfer-Ereignis beim Empfänger ankommt, nicht den Calldata-Betrag |
| Rebasing | Guthaben ändern sich ohne Transfer-Ereignis (das Angebot steigt oder sinkt, oder es laufen Rewards auf) | Lesen Sie balanceOf in bestimmten Blockhöhen aus; modellieren Sie das Rebase |
| Anteilsbasiert | Der Vertrag erfasst Anteile; angezeigte Guthaben werden daraus abgeleitet und auf wenige Basiseinheiten abgerundet | Erfassen Sie die Anteilseignisse, sofern vorhanden |
| Einfrierbar oder pausierbar | Der Emittent kann eine Adresse sperren oder ihr Guthaben über eigene Funktionen verbrennen | Dekodieren Sie auch die Admin-Ereignisse des Emittenten |
Lidos stETH ist ein bekanntes Beispiel für Rebasing: Die Guthaben der Inhaber steigen, wenn das Oracle des Protokolls Rewards meldet, ohne dass ein Transfer ausgegeben wird. Wegen der Rundung von Anteilen können bei Transfers außerdem 1–2 Wei zurückbleiben. stETH gibt neben jedem Transfer auch ein TransferShares-Ereignis aus. Der nicht rebasingfähige Wrapper wstETH verhält sich wie ein gewöhnlicher ERC-20. Denken Sie auch daran, dass bei Aufrufen zwischen Verträgen bewegtes natives ETH überhaupt kein Protokoll auslöst; es erscheint nur in Traces (siehe die Lektion Rohe Ethereum-Transaktionen, Receipts und Protokolle).
Eine Abstimmungsroutine
Bevor Sie Token-Flüsse für eine Adresse oder einen Vertrag melden, führen Sie eine kurze Prüfung durch:
- Legen Sie den Token anhand der Vertragsadresse und der Chain-ID fest und erfassen Sie
decimals(). - Rufen Sie Transfer-Protokolle mit dem richtigen topic0 und drei Topics vom Bereitstellungsblock des Vertrags bis zu Ihrem Stichtagsblock ab.
- Rekonstruieren Sie die Guthaben der relevanten Adressen.
- Vergleichen Sie sie mit
balanceOf, ausgelesen im Stichtagsblock, und das Gesamtangebot mittotalSupply(). - Erklären Sie jede Abweichung (Rebasing, Gebühren, eigene Mint-Ereignisse, WETH-artige Einzahlungen) oder nennen Sie sie als Einschränkung.
Die Lektion Guthaben und Historien rekonstruieren behandelt Guthabenschnappschüsse ausführlicher. Entscheidend ist hier, dass Token-Ereignisse Aussagen des Vertragscodes sind und Zustandsabfragen dazu dienen, sie zu überprüfen.
Prüfungsschwerpunkt: die Signatur des Transfer-Ereignisses und sein topic0 (0xddf252ad…); welche Parameter Topics und welche Daten sind; warum die Vertragsadresse des Protokolls und nicht das Symbol einen Token identifiziert; Rohwerte mit decimals umrechnen (6 gegenüber 18) und 256-Bit-Ganzzahlen sicher speichern; Mint- und Burn-Konventionen (Nulladresse) und warum WETH Deposit/Withdrawal sowie tote Adressen diese Konventionen durchbrechen; approve, transferFrom und Approval, einschließlich unbegrenzter Approvals und permit; ERC-20- und ERC-721-Transfers anhand der Topic-Anzahl unterscheiden; Token mit Transfergebühren und Rebasing-Token sowie der Abgleich mit balanceOf und totalSupply.