Interrogare le blockchain con SQL · Lezione 5 di 6
Interrogare log e tracce EVM
10 min di lettura
Sulle chain EVM, la maggior parte delle informazioni che interessano a un analista (movimenti di token, swap, approvazioni, prestiti) non è registrata nella transazione stessa, ma nei log degli eventi che essa emette e nelle chiamate interne che effettua. Le tabelle decodificate coprono i contratti più diffusi, ma prima o poi ci si imbatte in un caso che coinvolge un nuovo token, un contratto non verificato o uno schema che nessuno ha decodificato. Questa lezione mostra come filtrare, decodificare e aggregare log e tracce grezzi in SQL e come misurare il gas.
Le query usano lo schema generico della lezione Fondamenti di SQL per le tabelle blockchain: logs (contract_address, topic0 to topic3, data), traces (trace_address, trace_type, call_type, from_address, to_address, value, status, tx_status), transactions e blocks.
Firme degli eventi e topic0
Un log ha tre componenti: l'indirizzo del contratto che lo ha emesso, fino a quattro topic e un campo data (vedi la lezione Transazioni, ricevute e log grezzi di Ethereum). Per un normale evento Solidity, topic0 è l'hash Keccak-256 della firma canonica dell'evento: il nome e i tipi dei parametri, senza spazi né nomi dei parametri. I parametri dichiarati indexed finiscono in topic1–topic3, mentre gli altri vengono codificati in ABI nel campo data come parole da 32 byte.
| Evento | Firma canonica | topic0 |
|---|---|---|
| Transfer ERC-20 ed ERC-721 | Transfer(address,address,uint256) | 0xddf252ad…523b3ef |
| Approval ERC-20 | Approval(address,address,uint256) | 0x8c5be1e5…8c7c3b925 |
| TransferSingle ERC-1155 | TransferSingle(address,address,address,uint256,uint256) | 0xc3d58168…aa2d0f62 |
| Deposito di ether wrapped | Deposit(address,uint256) | 0xe1fffcc4…5cc9109c |
Filtrare per topic0 seleziona un tipo di evento tra tutti i contratti. Aggiungi contract_address quando ti riferisci a un token o protocollo specifico: chiunque può distribuire un contratto che emette un evento Transfer perfettamente formato, ed è proprio quello che fanno i token truffa per inserire trasferimenti falsi nella cronologia delle vittime. L'affidabilità di un evento dipende solo da quella del contratto che lo ha emesso.
ERC-20 ed ERC-721 condividono la stessa firma Transfer e quindi lo stesso topic0. La differenza riguarda l'indicizzazione: ERC-721 indicizza l'ID del token, quindi i suoi trasferimenti hanno quattro topic, mentre i trasferimenti ERC-20 hanno tre topic e riportano l'importo in data. Verificare topic3 IS NULL permette di distinguerli.
I set di dati reali spesso memorizzano i topic in un array anziché in quattro colonne, e l'indicizzazione degli array varia a seconda del motore: il primo topic è topics[OFFSET(0)] (o SAFE_OFFSET(0)) in BigQuery, che conta da zero, ma topics[1] in PostgreSQL, Trino e DuckDB, che contano da uno. Un errore in questo punto sposta ogni topic di una posizione senza generare errori.
Decodificare topic e data
I topic e le parole di data sono di 32 byte, scritti come 64 caratteri esadecimali dopo 0x. Un indirizzo occupa gli ultimi 20 byte (40 caratteri), riempiti a sinistra con zeri, quindi inizia al carattere 27 della stringa del topic. Una parola di data k (contando da zero) inizia al carattere 3 + 64k.
-- Decodifica gli eventi Transfer ERC-20 di un token dai log grezzi
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 -- prima parola da 32 byte
FROM logs
WHERE contract_address = '0x7a11…0001'
AND topic0 = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
AND topic3 IS NULL -- ERC-721 aggiunge un 4° topic (tokenId)
AND block_number BETWEEN 21000000 AND 21000999;
La query filtra per contratto, evento e numero di topic, quindi estrae le sottostringhe. In una riga di esempio, data termina con 77359400, che in decimale equivale a 2.000.000.000; con le 6 cifre decimali del token corrisponde a 2.000 token.
La conversione della parola esadecimale in numero è l'aspetto in cui i motori differiscono maggiormente:
| Motore | Conversione | Limite |
|---|---|---|
| DuckDB | CAST(CONCAT('0x', h) AS HUGEINT) | Intero con segno a 128 bit |
| Trino | from_base(h, 16) | BIGINT a 64 bit: non funziona oltre circa 9,22 × 10^18 |
| BigQuery | CAST(CONCAT('0x', h) AS INT64) per valori piccoli; funzione definita dall'utente per valori maggiori | INT64 a 64 bit |
| PostgreSQL | Nessuna funzione integrata per i valori grandi; funzione definita dall'utente | Dipende dalla funzione |
Il limite a 64 bit è l'insidia: 9,22 × 10^18 unità grezze equivalgono a soli 9,22 token con 18 cifre decimali, quindi le conversioni che funzionano sui dati di test falliscono o causano overflow sui trasferimenti reali. Per qualsiasi uso che vada oltre un controllo rapido, decodifica gli importi nel passaggio ETL o usa una tabella decodificata e memorizza il risultato come tipo decimale o numerico di precisione elevata. I tipi dinamici (stringhe, array) vengono codificati come offset e lunghezze ed è meglio gestirli con una libreria di decodifica ABI.
Alcuni valori speciali si possono verificare senza effettuare alcuna conversione. Un'approvazione «illimitata» imposta la disponibilità a 2^256 − 1, ovvero 64 caratteri f:
-- Approvazioni illimitate dei token: allowance = 2^256 - 1 (64 caratteri esadecimali '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;
Il confronto tra stringhe evita qualsiasi operazione aritmetica a 256 bit. L'elenco degli owner che hanno concesso approvazioni illimitate a un singolo spender è un passaggio standard per valutare la portata di una campagna di svuotamento dei wallet o di un protocollo compromesso (vedi la lezione Analizzare exploit e attacchi).
Trasferimenti interni dalle tracce
L'ETH inviato da un contratto, per esempio un prelievo da un pool o un pagamento da un multisig, non compare in transactions.value. Compare nelle tracce: una riga per ogni frame di chiamata, ottenuta rieseguendo la transazione. Colonne principali:
trace_address: posizione del frame nell'albero delle chiamate. In questo schema è testo:''per la chiamata di livello superiore,'0'per la sua prima sottochiamata,'0,1'per la seconda chiamata al suo interno. Alcuni set di dati lo memorizzano come array, nel quale la chiamata di livello superiore ha un array vuoto.trace_type:call,create(distribuzione di un contratto),suicide(nome storico di selfdestruct) oreward.call_type: per le chiamate,call,delegatecallostaticcall.status(il frame è riuscito) etx_status(l'intera transazione è riuscita).
-- Trasferimenti interni di ETH (inviati da contratti) verso un indirizzo
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 e staticcall non trasferiscono ETH
AND trace_address <> '' -- '' è la chiamata di livello superiore stessa
AND status = 1 -- il frame di chiamata è riuscito
AND tx_status = 1 -- ed è riuscita anche la transazione
AND block_number BETWEEN 21000000 AND 21050000;
Ogni filtro elimina un falso positivo noto. Una staticcall non può trasferire valore, e una delegatecall esegue il codice di un altro contratto nel contesto del chiamante senza spostare ETH, anche se alcuni formati di traccia mostrano un valore che in realtà non è mai stato trasferito. Escludere il frame di livello superiore evita di contare due volte il valore della transazione stessa, se interroghi anche transactions. Lo status è più importante per le tracce che per i log: una transazione annullata non emette alcun log, ma le sue tracce vengono comunque registrate, e un frame può essere annullato all'interno di una transazione che nel complesso riesce. Gli effetti di un frame persistono solo se esso e tutti i frame che lo precedono nell'albero sono riusciti; richiedere sia status sia tx_status copre i casi usuali, ma un annullamento intercettato da un contratto nel mezzo dell'albero può comunque sfuggire, quindi controlla singolarmente le transazioni insolite.
Per misurare tutto l'ETH ricevuto da un indirizzo, una regola più semplice consiste nell'usare solo le tracce, includendo il frame di livello superiore, anziché sommare transazioni e tracce. Ricorda che anche i frame create con valore e i frame selfdestruct trasferiscono ETH.
Analisi del gas
Dall'introduzione di EIP-1559 (agosto 2021), la commissione di ogni transazione si divide in una base fee bruciata e una priority fee pagata al produttore del blocco:
- commissione pagata = gas usato × prezzo effettivo del gas
- importo bruciato = gas usato × base fee per unità di gas (dal blocco)
- priority fee = gas usato × (prezzo effettivo del gas − base fee)
Le transazioni che trasportano blob (EIP-4844) pagano una blob fee separata, pari a gas blob usato × prezzo del gas blob, anch'essa bruciata e registrata in campi distinti della ricevuta.
-- Commissioni giornaliere, importi bruciati e priority fee, 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 -- anche le transazioni non riuscite pagano commissioni
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;
A differenza delle query sul valore, questa mantiene intenzionalmente le transazioni non riuscite, perché consumano gas e lo pagano. Il prodotto tra gas e un prezzo in wei supera facilmente i 64 bit (10 milioni di gas a 1.000 gwei equivalgono a 10^19 wei), un altro motivo per cui lo schema memorizza queste colonne come valori decimali. In un piccolo campione, un giorno registra 0,0239 ETH di commissioni, di cui solo 0,0030 ETH bruciati: una singola transazione che paga 1.000 gwei per unità di gas (una priority fee di 980 gwei oltre la base fee) domina il totale, un tipo di valore anomalo che merita un'analisi più approfondita.
I dati sul gas consentono di rispondere a diverse domande analitiche: quale indirizzo ha pagato il gas di un account (un collegamento forte, vedi la lezione Euristiche del modello account), quali mittenti pagano priority fee insolitamente alte (spesso bot o aggressori che agiscono in tempi critici) e quanto è costato eseguire un attacco. Sui rollup, le ricevute includono campi aggiuntivi per la commissione dei dati L1, i cui nomi variano da una chain all'altra.
Focus sull'esame:topic0come hash Keccak-256 della firma canonica dell'evento, con i parametri indicizzati nei topic e gli altri nei campi data da 32 byte; filtro per indirizzo del contratto per evitare eventi contraffatti; distinzione tra trasferimenti ERC-20 ed ERC-721 in base al numero di topic; array di topic indicizzati da zero o da uno ed estrazione di un indirizzo conSUBSTR(topic, 27, 40)e delle parole data a partire da 3 + 64k; rischio di overflow a 64 bit nella conversione degli importi; rilevamento delle approvazioni illimitate; filtri delle tracce per tipo di chiamata, indirizzo della traccia e status, e motivo per cui le transazioni annullate hanno tracce ma non log; formule EIP-1559 per commissione, importo bruciato e priority fee, e inclusione delle transazioni non riuscite nei totali delle commissioni.