Fluxos de tokens, NFTs e contratos inteligentes · Aula 3 de 6
Decodificando interações com contratos inteligentes
9 min de leitura
Toda interação com um contrato inteligente chega como bytes: uma string de calldata na transação e tópicos e dados nos logs. Transformar esses bytes em “quem chamou o quê, com quais argumentos e o que aconteceu” é a habilidade central da análise do modelo de contas. Isso depende de obter a ABI correta, para o código certo, no bloco certo, e de saber de onde vêm os hashes que identificam funções e eventos.
A ABI como chave de decodificação
A Application Binary Interface (ABI) é uma descrição JSON das funções, dos eventos e dos erros de um contrato: seus nomes, tipos de parâmetros, quais parâmetros de eventos são indexados e tipos de retorno. Os compiladores a geram a partir do código-fonte, e os exploradores a publicam para contratos verificados. Com a ABI, um decodificador sabe que determinado prefixo de 4 bytes significa transfer, que os próximos 32 bytes são um endereço e os 32 bytes seguintes são um valor.
A decodificação usa a assinatura canônica: o nome seguido pelos tipos dos parâmetros entre parênteses, separados por vírgulas, sem espaços e sem nomes de parâmetros. Os aliases são expandidos (uint vira uint256) e as structs são escritas como tuplas, por exemplo, aggregate3((address,bool,bytes)[]). Um único espaço extra ou um alias não expandido produz um hash completamente diferente, que é o motivo mais comum para um decodificador montado manualmente não encontrar nada.
Seletores de função
Um seletor de função são os primeiros 4 bytes do hash keccak-256 da assinatura canônica da função. keccak256("transfer(address,uint256)") começa com 0xa9059cbb, então toda chamada a transfer de um ERC-20 começa com esses 4 bytes. Depois do seletor, os argumentos são codificados em ABI em palavras de 32 bytes: tipos estáticos (address, uint256, bool) ficam em seus lugares, preenchidos à esquerda; tipos dinâmicos (bytes, string, arrays) deixam um deslocamento em seu lugar e colocam o comprimento e o conteúdo no final.
a9059cbb seletor: transfer(address,uint256)
000000000000000000000000b0b5e6f7…41aa arg. 1: endereço do destinatário
0000000000000000000000000000000000000000000000000000000077359400 arg. 2: 2.000.000.000 unidades-base
Esses dados de chamada enviam 2.000.000.000 unidades-base, ou 2.000 tokens se o token tiver 6 casas decimais, ao destinatário. Observe que o destinatário não é o campo to da transação, que corresponde ao contrato do token.
Quatro bytes permitem cerca de 4,3 bilhões de valores, e existem colisões de seletores: burn(uint256) e collate_propagate_storage(bytes16) resultam ambos no hash 0x42966c68. Bancos de dados abertos de assinaturas (como o diretório 4byte e a consulta de assinaturas da Sourcify/OpenChain) associam seletores a assinaturas em texto, mas podem retornar vários candidatos para um único seletor, e qualquer pessoa pode enviar entradas. Prefira a ABI verificada do contrato que você está decodificando; use bancos de dados para contratos não verificados e escolha entre os candidatos verificando se os argumentos são decodificados corretamente. Um seletor que o contrato não reconhece é encaminhado para sua função fallback, então chamadas com “seletor desconhecido” ainda podem fazer algo.
O mesmo esquema identifica os dados de revert, que explicam por que uma transação falhou. Uma mensagem simples de require é codificada como Error(string), seletor 0x08c379a0, seguida pelo texto codificado em ABI; verificações inseridas pelo compilador, como as de estouro aritmético, usam Panic(uint256), seletor 0x4e487b71, com um código numérico; e os contratos podem definir erros personalizados, cujos seletores são derivados exatamente como os seletores de função e aparecem na ABI. Decodificar os motivos de revert das tentativas malsucedidas de um invasor antes de uma exploração bem-sucedida muitas vezes revela o que ele estava testando.
Assinaturas de eventos e tópicos
Os eventos usam o hash completo de 32 bytes da assinatura canônica do evento como topic0. Um evento não anônimo pode ter até três parâmetros indexados, nos tópicos topic1 a topic3. Eventos anônimos omitem topic0, liberando um quarto espaço indexado, mas tornando-os invisíveis para filtros que buscam pela assinatura. Parâmetros indexados de tipo dinâmico (string, bytes, arrays) são armazenados como o hash keccak-256 do valor, portanto o valor original não pode ser recuperado do tópico.
Um conjunto de referência de valores topic0, verificados por meio do hash das assinaturas:
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
As duas linhas de Swap correspondem aos eventos de par do Uniswap v2 e de pool do Uniswap v3 (consulte a aula Swaps em DEX e fluxos de liquidez). Da tabela, decorrem duas lições. Primeiro, topic0 informa o tipo de evento, e o campo address do log informa qual contrato o emitiu; um filtro apenas por topic0 retorna eventos de todos os contratos que usam essa assinatura, inclusive falsificações. Segundo, eventos com os mesmos tipos, mas com indexação diferente, compartilham um topic0, como ocorre com Transfer de ERC-20 e ERC-721, então verifique o número de tópicos antes de decodificar.
Contratos proxy e consulta da implementação
Muitos contratos são proxies: contratos pequenos que mantêm o armazenamento e os saldos e encaminham todas as chamadas, usando delegatecall, para um contrato de implementação que contém a lógica. Fazer uma atualização significa apontar o proxy para uma nova lógica. Para um analista, isso tem três consequências:
- A ABI necessária é a da implementação, não a do proxy.
- Os logs são emitidos com o endereço do proxy, porque o código é executado no contexto do proxy. A identidade de um token permanece sendo o endereço do proxy após as atualizações.
- A implementação pode mudar ao longo do tempo, então decodifique usando a implementação que estava ativa no bloco da transação.
O EIP-1967 padronizou os locais em que os proxies armazenam esses ponteiros, para que as ferramentas possam encontrá-los. Cada slot é o keccak-256 de uma string fixa menos 1:
| Finalidade | String hasheada | Slot |
|---|---|---|
| Implementação | eip1967.proxy.implementation | 0x3608…2bbc |
| Administrador | eip1967.proxy.admin | 0xb531…6103 |
| Beacon | eip1967.proxy.beacon | 0xa3f0…3d50 |
A leitura do slot da implementação em determinado bloco é uma chamada JSON-RPC:
{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getStorageAt",
"params": [
"0x9c4e…1d27",
"0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc",
"0x1406f40"
]
}
A resposta é uma palavra de 32 bytes, por exemplo, 0x000000000000000000000000e5a1…7c30; os últimos 20 bytes são o endereço da implementação no bloco 21.000.000. Leituras históricas exigem um nó de arquivo (consulte a aula APIs de nós e acesso a dados). O proxy também emite Upgraded(address indexed implementation) a cada alteração, então buscar esses logs fornece toda a linha do tempo das atualizações:
{
"address": "0x9c4e…1d27",
"topics": [
"0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b",
"0x000000000000000000000000e5a1…7c30"
],
"data": "0x",
"blockNumber": "0x13d6200",
"decoded": {"event": "Upgraded", "proxy": "0x9c4e…1d27", "implementation": "0xe5a1…7c30", "block": 20800000}
}
O campo address do log é o proxy, e seu único parâmetro indexado é a nova implementação. Aqui, a implementação lida no bloco 21.000.000 foi instalada no bloco 20.800.000; transações anteriores a esse bloco precisam ser decodificadas com a ABI da implementação anterior.
Outros padrões exigem outras consultas:
- Proxies Transparent e UUPS (no estilo EIP-1822, conforme implementado pela OpenZeppelin) usam ambos o slot de implementação do EIP-1967; diferem no local em que fica a função de atualização.
- Proxies Beacon armazenam um endereço de beacon; chame
implementation()no beacon. - Proxies mínimos (clones EIP-1167) codificam o endereço da implementação no bytecode de runtime de 45 bytes, entre o prefixo
363d3d373d3d3d363d73e o sufixo5af43d82803e903d91602b57fd5bf3. - Proxies de multisig Safe mantêm sua implementação (o singleton, historicamente chamado de
masterCopy) no slot de armazenamento 0, e não em um slot EIP-1967. O roubo da Bybit de fevereiro de 2025 sobrescreveu exatamente esse slot (consulte a aula Análise de explorações e ataques). - Diamonds (EIP-2535) encaminham cada seletor para um contrato facet diferente; é necessário consultar a tabela de seletor para facet no bloco.
Código-fonte verificado
Um explorador ou o repositório aberto Sourcify marca um contrato como verificado quando compilar o código-fonte publicado com as configurações indicadas reproduz o bytecode implantado. A Sourcify diferencia uma correspondência completa (exata), em que até o hash dos metadados incorporados corresponde, de uma correspondência parcial, em que apenas o código executável corresponde. A verificação prova que o código-fonte corresponde ao bytecode. Não prova que o código é seguro, auditado ou honesto, e o nome do contrato no código-fonte verificado é escolhido por quem o implanta. Para contratos não verificados, os analistas combinam consultas de seletores, resultados de descompiladores e comportamentos observados em traces, ajustando sua confiança de acordo com isso.
Multicall e chamadas em lote
O agrupamento oculta ações atrás de uma única chamada externa. Formas comuns:
- Contratos Multicall, como
aggregate3((address,bool,bytes)[])do Multicall3 (seletor 0x82ad56cb), que executa uma lista de chamadas. multicall(bytes[])(0xac9650d8) emulticall(uint256,bytes[])(0x5ae401dc, com um prazo) de roteadores, em que cada elemento é calldata para outra função do mesmo roteador.- Lotes de multisig (MultiSend do Safe) e lotes de contas inteligentes (
handleOpsdo ERC-4337, contas delegadas do EIP-7702).
O seletor externo indica apenas “lote”. Decodifique recursivamente: decodifique a chamada externa, obtenha cada elemento bytes interno, leia seu seletor e decodifique-o com a ABI do destino. Em seguida, confirme com o trace, a árvore de chamadas que mostra o destino, o valor e o sucesso de cada chamada interna, pois um lote pode permitir que chamadas individuais falhem. Os logs de todas as chamadas internas aparecem no mesmo recibo, ordenados por logIndex, o que permite associá-los às etapas. A aula Consultando logs e traces de EVM mostra como fazer isso em escala.
Foco no exame: assinaturas canônicas (sem espaços, uint256, tuplas); derivação de um seletor de função (os primeiros 4 bytes do keccak-256, por exemplo, 0xa9059cbb para transfer) e leitura das palavras de calldata; colisões de seletores e como escolher entre candidatos de bancos de dados; topic0 como hash completo da assinatura do evento, limites de parâmetros indexados, eventos anônimos e tópicos dinâmicos hasheados; proxies: os logs carregam o endereço do proxy, slot de implementação do EIP-1967 (keccak-256 de "eip1967.proxy.implementation" menos 1), eth_getStorageAt em um bloco, eventos Upgraded, padrões de beacon, proxy mínimo e slot 0 de Safe; o que a verificação prova e o que não prova; decodificação recursiva de lotes multicall e confirmação com traces.