Flussi di token, NFT e smart contract · Lezione 3 di 6
Decodificare le interazioni con gli smart contract
9 min di lettura
Ogni interazione con uno smart contract arriva sotto forma di byte: una stringa calldata nella transazione e topic e dati nei log. Trasformare quei byte in «chi ha chiamato cosa, con quali argomenti e che cosa è successo» è la competenza fondamentale nell’analisi del modello account. Per farlo occorre usare l’ABI corretta per il codice giusto al blocco giusto e sapere da dove provengono gli 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: nomi, tipi dei parametri, indicazione dei parametri indicizzati negli eventi e tipi restituiti. I compilatori la generano dal codice sorgente e gli explorer la pubblicano per i contratti verificati. Con l’ABI, un decoder sa che un determinato prefisso di 4 byte significa transfer, che i 32 byte successivi sono un indirizzo e i 32 byte seguenti un importo.
La decodifica usa la firma canonica: il nome seguito dai tipi dei parametri tra parentesi, separati da virgole, senza spazi né nomi dei parametri. Gli alias vengono espansi (uint diventa uint256) e le struct si scrivono come tuple, per esempio aggregate3((address,bool,bytes)[]). Un solo spazio in più o un alias non espanso produce un hash completamente diverso: è il motivo più comune per cui un decoder costruito a mano non trova nulla.
Selector delle funzioni
Un selector 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 transfer di un token ERC-20 inizia con questi 4 byte. Dopo il selector, gli argomenti sono codificati secondo l’ABI in parole da 32 byte: i tipi statici (address, uint256, bool) occupano direttamente la loro posizione, con padding a sinistra; i tipi dinamici (bytes, string, array) lasciano un offset nella posizione e collocano la lunghezza e il contenuto alla fine.
a9059cbb selector: transfer(address,uint256)
000000000000000000000000b0b5e6f7…41aa argomento 1: indirizzo del destinatario
0000000000000000000000000000000000000000000000000000000077359400 argomento 2: 2.000.000.000 unità base
Questa calldata invia 2.000.000.000 unità base, cioè 2.000 token se il token ha 6 decimali, al destinatario. Il destinatario non è il campo to della transazione, che contiene invece l’indirizzo del contratto del token.
Quattro byte consentono circa 4,3 miliardi di valori e si verificano collisioni tra selector: burn(uint256) e collate_propagate_storage(bytes16) hanno entrambi hash 0x42966c68. I database aperti delle firme (come la directory 4byte e la ricerca di firme Sourcify/OpenChain) associano i selector alle firme testuali, ma possono restituire più candidati per un singolo selector e chiunque può inviare voci. Preferisci l’ABI verificata del contratto che stai decodificando; usa i database per i contratti non verificati e scegli tra i candidati verificando che gli argomenti si decodifichino correttamente. Un selector non riconosciuto dal contratto viene instradato alla sua funzione fallback, quindi anche le chiamate con «selector sconosciuto» possono fare qualcosa.
Lo stesso schema identifica i dati di revert, che spiegano il motivo del fallimento di una transazione. Un messaggio semplice di require viene codificato come Error(string), selector 0x08c379a0, seguito dal testo codificato secondo l’ABI; i controlli inseriti dal compilatore, come quelli per l’overflow aritmetico, usano Panic(uint256), selector 0x4e487b71, con un codice numerico; inoltre, i contratti possono definire errori personalizzati, i cui selector si ricavano esattamente come quelli delle funzioni e compaiono nell’ABI. Decodificare i motivi dei revert dei tentativi falliti di un attaccante prima di un exploit riuscito spesso rivela che cosa stava testando.
Firme degli eventi e topic
Gli eventi usano l’hash completo di 32 byte della firma canonica dell’evento come topic0. Un evento non anonimo può avere fino a tre parametri indicizzati nei topic1, topic2 e topic3. Gli eventi anonimi omettono topic0, liberando così un quarto slot indicizzato, ma non sono visibili ai filtri che cercano gli eventi in base alla 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 ricavato dal topic.
Un insieme di riferimento di valori topic0, verificati calcolando gli 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 della pair Uniswap v2 e del pool Uniswap v3 (vedi la lezione Scambi DEX e flussi di liquidità). Dalla tabella si ricavano due insegnamenti. Primo, topic0 indica il tipo di evento, mentre il campo address del log indica quale contratto lo ha emesso; un filtro basato solo su topic0 restituisce gli eventi di tutti i contratti che usano quella firma, compresi quelli falsi. Secondo, gli eventi con gli stessi tipi ma indicizzazione diversa condividono topic0, come accade per Transfer di ERC-20 e ERC-721: controlla quindi il numero di topic prima di decodificare.
Contratti proxy e ricerca dell’implementazione
Molti contratti sono proxy: contratti di piccole dimensioni che detengono lo storage e i saldi e inoltrano ogni chiamata, tramite delegatecall, a un contratto implementazione che contiene la logica. L’aggiornamento consiste nell’indirizzare il proxy verso una nuova logica. Per un analista questo comporta tre conseguenze:
- Serve l’ABI 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à del token rimane l’indirizzo del proxy anche dopo gli aggiornamenti.
- L’implementazione può cambiare nel tempo, quindi bisogna decodificare usando quella attiva al blocco della transazione.
EIP-1967 ha standardizzato le posizioni in cui i proxy memorizzano questi puntatori, così gli strumenti possono individuarli. Ogni slot è keccak-256 di una stringa fissa meno 1:
| Scopo | Stringa sottoposta a hash | Slot |
|---|---|---|
| Implementazione | eip1967.proxy.implementation | 0x3608…2bbc |
| Admin | eip1967.proxy.admin | 0xb531…6103 |
| Beacon | eip1967.proxy.beacon | 0xa3f0…3d50 |
Leggere lo slot dell’implementazione a un determinato blocco richiede una sola chiamata JSON-RPC:
{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getStorageAt",
"params": [
"0x9c4e…1d27",
"0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc",
"0x1406f40"
]
}
La risposta è una parola di 32 byte, per esempio 0x000000000000000000000000e5a1…7c30; gli ultimi 20 byte sono l’indirizzo dell’implementazione al blocco 21.000.000. Le letture storiche richiedono un nodo archive (vedi la lezione API dei nodi e accesso ai dati). Il proxy emette inoltre Upgraded(address indexed implementation) a ogni modifica, quindi recuperando questi log si ottiene 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 è quello del 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 quel blocco devono essere decodificate con l’ABI dell’implementazione precedente.
Altri schemi richiedono ricerche diverse:
- I proxy Transparent e UUPS (stile EIP-1822, come implementati da OpenZeppelin) usano entrambi lo slot dell’implementazione EIP-1967; differiscono per la posizione della funzione di aggiornamento.
- I proxy Beacon memorizzano l’indirizzo di un beacon: chiama
implementation()sul beacon. - I proxy minimali (clone EIP-1167) codificano l’indirizzo dell’implementazione nei 45 byte del bytecode runtime, tra il prefisso
363d3d373d3d3d363d73e il suffisso5af43d82803e903d91602b57fd5bf3. - I proxy multisig Safe conservano la loro implementazione (il singleton, storicamente chiamato
masterCopy) nello slot di storage 0, non in uno slot EIP-1967. Il furto ai danni di Bybit del febbraio 2025 ha sovrascritto proprio quello slot (vedi la lezione Analizzare exploit e attacchi informatici). - I Diamond (EIP-2535) instradano ogni selector verso un facet diverso: serve la tabella che associa selector e facet valida al blocco interessato.
Codice sorgente verificato
Un explorer o il repository aperto Sourcify contrassegna un contratto come verificato quando la compilazione del codice sorgente pubblicato con le impostazioni dichiarate riproduce il bytecode distribuito. Sourcify distingue una corrispondenza completa (esatta), in cui coincide anche l’hash dei metadati incorporato, da una corrispondenza parziale, in cui coincide solo il codice eseguibile. La verifica dimostra che il codice 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 selector, output del decompilatore e comportamenti osservati nelle trace, riducendo di conseguenza il livello di fiducia.
Multicall e chiamate raggruppate
Il raggruppamento nasconde le azioni dietro un’unica chiamata esterna. Le forme più comuni sono:
- I contratti Multicall, come
aggregate3((address,bool,bytes)[])di Multicall3 (selector 0x82ad56cb), che esegue un elenco di chiamate. multicall(bytes[])dei router (0xac9650d8) emulticall(uint256,bytes[])(0x5ae401dc, con una scadenza), in cui ogni elemento è calldata per un’altra funzione dello stesso router.- I raggruppamenti multisig (MultiSend di Safe) e quelli degli smart account (
handleOpsdi ERC-4337, account delegati EIP-7702).
Il selector esterno indica solo «raggruppamento». Decodifica ricorsivamente: decodifica la chiamata esterna, prendi ogni elemento interno bytes, leggi il suo selector e decodificalo con l’ABI del target. Poi verifica con la trace, l’albero delle chiamate che mostra il target, il valore e l’esito di ogni chiamata interna, perché un raggruppamento può consentire il fallimento di singole chiamate. I log di tutte le chiamate interne compaiono nella stessa receipt, ordinati per logIndex, che li collega ai vari passaggi. La lezione Interrogare i log e le trace EVM mostra come eseguire questa operazione su larga scala.
Focus sull’esame: firme canoniche (senza spazi, uint256, tuple); calcolo di un selector di funzione (i primi 4 byte di keccak-256, per esempio 0xa9059cbb per transfer) e lettura delle parole di calldata; collisioni tra selector e scelta tra i candidati dei database; topic0 come hash completo della firma di un 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 blocco, eventi Upgraded, schemi Beacon, Minimal e Safe con slot 0; cosa dimostra e cosa non dimostra la verifica; decodifica ricorsiva dei raggruppamenti multicall e verifica tramite trace.