Cross-chain, DeFi e smart contract · Lezione 1 di 6
Decodifica delle interazioni con gli smart contract
9 min di lettura
Ogni interazione con uno smart contract arriva sotto forma di byte: una stringa di calldata nella transazione e topic e dati nei log. Trasformare questi byte in «chi ha chiamato cosa, con quali argomenti e che cosa è successo» è la competenza fondamentale dell'analisi del modello a conti. Dipende dalla disponibilità dell'ABI corretta, per il codice corretto, al blocco corretto, e dalla conoscenza dell'origine degli hash che identificano funzioni ed eventi.
L'ABI come chiave di decodifica
L'Application Binary Interface (ABI) è una descrizione JSON delle funzioni, degli eventi e degli errori di un contratto: i loro nomi, i tipi dei parametri, quali parametri degli eventi sono indicizzati e i tipi restituiti. I compilatori la producono a partire dal codice sorgente e gli explorer la pubblicano per i contratti verificati. Con l'ABI, un decodificatore sa che un determinato prefisso di 4 byte indica transfer, che i 32 byte successivi sono un indirizzo e che i 32 byte seguenti sono un importo.
La decodifica utilizza la firma canonica: il nome seguito dai tipi dei parametri tra parentesi, separati da virgole, senza spazi e senza nomi dei parametri. Gli alias vengono espansi (uint diventa uint256) e le strutture vengono scritte come tuple, ad esempio aggregate3((address,bool,bytes)[]). Un singolo spazio in più o un alias non espanso produce un hash completamente diverso: è il motivo più comune per cui un decodificatore costruito manualmente non trova nulla.
Selettori di funzione
Un selettore di funzione è costituito dai primi 4 byte dell'hash keccak-256 della firma canonica della funzione. keccak256("transfer(address,uint256)") inizia con 0xa9059cbb, quindi ogni chiamata a una funzione transfer ERC-20 inizia con questi 4 byte. Dopo il selettore, gli argomenti sono codificati secondo l'ABI in parole di 32 byte: i tipi statici (address, uint256, bool) occupano direttamente la propria posizione, con riempimento a sinistra; i tipi dinamici (bytes, string, array) lasciano un offset nella propria posizione e collocano la lunghezza e il contenuto alla fine.
a9059cbb selettore: transfer(address,uint256)
000000000000000000000000b0b5e6f7…41aa arg 1: indirizzo del destinatario
0000000000000000000000000000000000000000000000000000000077359400 arg 2: 2.000.000.000 unità di base
Questi calldata inviano al destinatario 2.000.000.000 di unità di base, ossia 2.000 token se il token ha 6 decimali. Si noti che il destinatario non è il campo to della transazione, che corrisponde al contratto del token.
Quattro byte consentono circa 4,3 miliardi di valori ed esistono collisioni tra selettori: burn(uint256) e collate_propagate_storage(bytes16) producono entrambi 0x42966c68. I database aperti di firme (come la directory 4byte e il servizio di ricerca delle firme Sourcify/OpenChain) associano i selettori alle firme testuali, ma possono restituire più candidati per un singolo selettore e chiunque può aggiungere voci. Preferire l'ABI verificata del contratto che si sta decodificando; utilizzare i database per i contratti non verificati e scegliere tra i candidati controllando che gli argomenti vengano decodificati correttamente. Un selettore che il contratto non riconosce viene indirizzato alla sua funzione di fallback, quindi le chiamate con «selettore sconosciuto» possono comunque eseguire operazioni.
Lo stesso schema identifica i dati di revert, che spiegano perché una transazione non è riuscita. Un semplice messaggio di require viene codificato come Error(string), selettore 0x08c379a0, seguito dal testo codificato secondo l'ABI; i controlli inseriti dal compilatore, come quelli per l'overflow aritmetico, utilizzano Panic(uint256), selettore 0x4e487b71, con un codice numerico; inoltre, i contratti possono definire errori personalizzati, i cui selettori sono derivati esattamente come i selettori di funzione e compaiono nell'ABI. Decodificare i motivi di revert dei tentativi falliti di un attaccante prima di un exploit riuscito mostra spesso che cosa stesse testando.
Firme degli eventi e topic
Gli eventi utilizzano l'intero hash di 32 byte della firma canonica dell'evento come topic0. Un evento non anonimo può avere fino a tre parametri indicizzati, da topic1 a topic3. Gli eventi anonimi omettono topic0, liberando una quarta posizione indicizzata, ma diventando invisibili ai filtri che effettuano ricerche per firma. I parametri indicizzati di tipo dinamico (string, bytes, array) vengono memorizzati come hash keccak-256 del valore, quindi il valore originale non può essere recuperato dal topic.
Un insieme di riferimento di valori topic0, verificati calcolando l'hash delle firme:
Transfer(address,address,uint256) 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
Approval(address,address,uint256) 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925
ApprovalForAll(address,address,bool) 0x17307eab39ab6107e8899845ad3d59bd9653f200f220920489ca2b5937696c31
TransferSingle(address,address,address,uint256,uint256) 0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62
TransferBatch(address,address,address,uint256[],uint256[]) 0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb
Swap(address,uint256,uint256,uint256,uint256,address) 0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822
Swap(address,address,int256,int256,uint160,uint128,int24) 0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67
Upgraded(address) 0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b
Le due righe Swap corrispondono agli eventi delle coppie Uniswap v2 e dei pool Uniswap v3 (si veda la lezione Swap sui DEX, exchange istantanei e flussi di liquidità). Dalla tabella si ricavano due insegnamenti. Innanzitutto, topic0 indica il tipo di evento e il campo address del log indica quale contratto lo ha emesso; un filtro basato soltanto su topic0 restituisce eventi di tutti i contratti che utilizzano quella firma, inclusi quelli falsi. In secondo luogo, gli eventi con gli stessi tipi ma con un'indicizzazione diversa condividono lo stesso topic0, come avviene per Transfer di ERC-20 e di ERC-721: occorre quindi controllare il numero di topic prima della decodifica.
Contratti proxy e ricerca dell'implementazione
Molti contratti sono proxy: un piccolo contratto che conserva lo storage e i saldi e inoltra ogni chiamata, tramite delegatecall, a un contratto di implementazione che contiene la logica. Un aggiornamento consiste nel far puntare il proxy a una nuova logica. Per un analista ciò comporta tre conseguenze:
- L'ABI necessaria è quella dell'implementazione, non quella del proxy.
- I log vengono emessi con l'indirizzo del proxy, perché il codice viene eseguito nel contesto del proxy. L'identità di un token rimane l'indirizzo del proxy anche dopo gli aggiornamenti.
- L'implementazione può cambiare nel tempo, quindi occorre decodificare con l'implementazione attiva al blocco della transazione.
EIP-1967 ha standardizzato le posizioni in cui i proxy memorizzano questi puntatori, affinché gli strumenti possano trovarli. Ogni slot è l'hash keccak-256 di una stringa fissa meno 1:
| Scopo | Stringa sottoposta a hash | Slot |
|---|---|---|
| Implementazione | eip1967.proxy.implementation | 0x3608…2bbc |
| Amministratore | eip1967.proxy.admin | 0xb531…6103 |
| Beacon | eip1967.proxy.beacon | 0xa3f0…3d50 |
La lettura dello slot dell'implementazione a un determinato blocco richiede una chiamata JSON-RPC:
{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getStorageAt",
"params": [
"0x9c4e…1d27",
"0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc",
"0x1406f40"
]
}
La risposta è una parola di 32 byte, ad esempio 0x000000000000000000000000e5a1…7c30; gli ultimi 20 byte sono l'indirizzo dell'implementazione al blocco 21.000.000. Le letture storiche richiedono un nodo archivio (si veda la lezione Blocchi, intestazioni e API dei nodi). Il proxy emette inoltre Upgraded(address indexed implementation) a ogni modifica, quindi il recupero di questi log fornisce la cronologia completa degli aggiornamenti:
{
"address": "0x9c4e…1d27",
"topics": [
"0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b",
"0x000000000000000000000000e5a1…7c30"
],
"data": "0x",
"blockNumber": "0x13d6200",
"decoded": {"event": "Upgraded", "proxy": "0x9c4e…1d27", "implementation": "0xe5a1…7c30", "block": 20800000}
}
Il campo address del log è il proxy e il suo unico parametro indicizzato è la nuova implementazione. In questo caso, l'implementazione letta al blocco 21.000.000 è stata installata al blocco 20.800.000; le transazioni precedenti a tale blocco devono essere decodificate con l'ABI dell'implementazione precedente.
Altri modelli richiedono ricerche diverse:
- I proxy trasparenti e UUPS (secondo lo stile EIP-1822, come implementato da OpenZeppelin) utilizzano entrambi lo slot dell'implementazione EIP-1967; differiscono per la posizione della funzione di aggiornamento.
- I proxy beacon memorizzano l'indirizzo di un beacon; occorre chiamare
implementation()sul beacon. - I proxy minimi (cloni EIP-1167) incorporano direttamente l'indirizzo dell'implementazione nel proprio bytecode di runtime di 45 byte, tra il prefisso
363d3d373d3d3d363d73e il suffisso5af43d82803e903d91602b57fd5bf3. - I proxy multifirma Safe conservano la propria implementazione (il singleton, storicamente chiamato
masterCopy) nello slot di storage 0, non in uno slot EIP-1967. Il furto a Bybit del febbraio 2025 ha sovrascritto esattamente quello slot (si veda la lezione Analisi di exploit e attacchi informatici). - I diamond (EIP-2535) instradano ciascun selettore verso un diverso contratto facet; è necessaria la tabella di associazione tra selettori e facet al blocco in esame.
Codice sorgente verificato
Un explorer o il repository aperto Sourcify contrassegna un contratto come verificato quando la compilazione del sorgente pubblicato con le impostazioni dichiarate riproduce il bytecode distribuito. Sourcify distingue una corrispondenza completa (esatta), in cui coincide anche l'hash dei metadati incorporati, da una corrispondenza parziale, in cui coincide soltanto il codice eseguibile. La verifica dimostra che il sorgente corrisponde al bytecode. Non dimostra che il codice sia sicuro, sottoposto ad audit o onesto, e il nome del contratto nel sorgente verificato è scelto da chi lo distribuisce. Per i contratti non verificati, gli analisti combinano ricerche dei selettori, output dei decompilatori e comportamento osservato nelle tracce, riducendo di conseguenza il proprio livello di confidenza.
Multicall e chiamate in batch
Il raggruppamento in batch nasconde le azioni dietro un'unica chiamata esterna. Forme comuni:
- Contratti Multicall, come
aggregate3((address,bool,bytes)[])di Multicall3 (selettore 0x82ad56cb), che esegue un elenco di chiamate. - Le funzioni dei router
multicall(bytes[])(0xac9650d8) emulticall(uint256,bytes[])(0x5ae401dc, con una scadenza), in cui ogni elemento contiene i calldata per un'altra funzione dello stesso router. - Batch multifirma (MultiSend di Safe) e batch di smart account (
handleOpsdi ERC-4337, account delegati EIP-7702).
Il selettore esterno indica soltanto «batch». Occorre decodificare ricorsivamente: decodificare la chiamata esterna, prendere ciascun elemento bytes interno, leggerne il selettore e decodificarlo con l'ABI del destinatario. Quindi occorre verificare rispetto alla traccia, l'albero delle chiamate che mostra il destinatario, il valore e l'esito di ogni chiamata interna, perché un batch può consentire il fallimento di singole chiamate. I log di tutte le chiamate interne compaiono in un'unica ricevuta, ordinati per logIndex, che li ricollega ai vari passaggi. La lezione Interrogazione dei dati UTXO e dei log e delle tracce EVM mostra come farlo su larga scala.
Focus sull'esame: firme canoniche (senza spazi, uint256, tuple); derivazione di un selettore di funzione (primi 4 byte di keccak-256, ad esempio 0xa9059cbb per transfer) e lettura delle parole dei calldata; collisioni tra selettori e criteri di scelta tra i candidati dei database; topic0 come hash completo della firma dell'evento, limiti dei parametri indicizzati, eventi anonimi e topic dinamici sottoposti a hash; proxy: i log riportano l'indirizzo del proxy, lo slot dell'implementazione EIP-1967 (keccak-256 di "eip1967.proxy.implementation" meno 1), eth_getStorageAt a un determinato blocco, eventi Upgraded, modelli beacon, minimi e Safe con slot 0; che cosa dimostra e che cosa non dimostra la verifica; decodifica ricorsiva dei batch multicall e conferma mediante le tracce.
Domande di esercitazione
Verifica la tua comprensione. Queste domande non contano per l’esame.
Accedi per salvare i tuoi progressi e riprendere da dove hai lasciato. È gratuito. Accedi