Cross-chain, DeFi e contratos inteligentes · Aula 1 de 6
Decodificação de 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 correto, no bloco correto, e de saber de onde vêm os hashes que identificam funções e eventos.
A ABI como chave de decodificação
A Interface Binária de Aplicação (ABI) é uma descrição em 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 produzem 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, uma quantia.
A decodificação usa a assinatura canônica: o nome seguido dos tipos de parâmetros entre parênteses, separados por vírgulas, sem espaços e sem nomes de parâmetros. Os aliases são expandidos (uint se torna 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, o que é o motivo mais comum para um decodificador construído manualmente não encontrar nada.
Seletores de função
Um seletor de função corresponde aos primeiros 4 bytes do hash keccak-256 da assinatura canônica da função. keccak256("transfer(address,uint256)") começa com 0xa9059cbb, portanto toda chamada a transfer de um ERC-20 começa com esses 4 bytes. Após o seletor, os argumentos são codificados conforme a ABI em palavras de 32 bytes: os tipos estáticos (address, uint256, bool) ficam em suas posições, com preenchimento à esquerda; os tipos dinâmicos (bytes, string, arrays) deixam um deslocamento na posição e colocam seu comprimento e 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 básicas
Essa calldata envia 2.000.000.000 de unidades básicas, 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 é o 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) produzem o mesmo hash 0x42966c68. Bases de dados abertas de assinaturas (como o diretório 4byte e a consulta de assinaturas Sourcify/OpenChain) mapeiam seletores de volta para assinaturas textuais, mas podem retornar vários candidatos para um seletor, e qualquer pessoa pode enviar entradas. Prefira a ABI verificada do contrato que você está decodificando; use bases 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 à sua função fallback, portanto chamadas com "seletor desconhecido" ainda podem fazer algo.
O mesmo esquema identifica os dados de reversão, que explicam por que uma transação falhou. Uma mensagem simples de require é codificada como Error(string), seletor 0x08c379a0, seguida do texto codificado conforme a ABI; verificações inseridas pelo compilador, como 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 reversão das tentativas malsucedidas de um atacante antes de uma exploração bem-sucedida frequentemente mostra 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 em topic1 a topic3. Eventos anônimos omitem topic0, o que libera uma quarta posição indexada, mas os torna invisíveis para filtros que pesquisam por 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 a partir do tópico.
Um conjunto de referência de valores de topic0, verificados pelo cálculo 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 Swap são os eventos de par do Uniswap v2 e de pool do Uniswap v3 (consulte a aula Swaps em DEX, exchanges instantâneas e fluxos de liquidez). Duas conclusões decorrem da tabela. Primeiro, topic0 informa o tipo de evento e o address do log informa qual contrato o emitiu; um filtro apenas por topic0 retorna eventos de todos os contratos que usam a assinatura, incluindo 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, portanto verifique o número de tópicos antes de decodificar.
Contratos proxy e consulta da implementação
Muitos contratos são proxies: um pequeno contrato que mantém o armazenamento e os saldos e encaminha todas as chamadas, com delegatecall, para um contrato de implementação que contém a lógica. Atualizar significa apontar o proxy para uma nova lógica. Para um analista, isso tem três consequências:
- A ABI de que você precisa é 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 continua sendo o endereço do proxy ao longo das atualizações.
- A implementação pode mudar ao longo do tempo, portanto decodifique com a implementação que estava ativa no bloco da transação.
A EIP-1967 padronizou onde 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 cujo hash é calculado | Slot |
|---|---|---|
| Implementação | eip1967.proxy.implementation | 0x3608…2bbc |
| Administrador | eip1967.proxy.admin | 0xb531…6103 |
| Beacon | eip1967.proxy.beacon | 0xa3f0…3d50 |
Ler o slot de implementação em um determinado bloco exige 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 precisam de um nó de arquivo (consulte a aula Blocos, cabeçalhos e APIs de nós). O proxy também emite Upgraded(address indexed implementation) a cada alteração, portanto obter esses logs fornece a linha do tempo completa 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 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 devem ser decodificadas com a ABI da implementação anterior.
Outros padrões precisam de outras consultas:
- Proxies transparentes e UUPS (no estilo EIP-1822, conforme implementado pela OpenZeppelin) usam o slot de implementação da EIP-1967; eles diferem quanto ao local onde 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) têm o endereço da implementação fixado em seu bytecode de execução de 45 bytes, entre o prefixo
363d3d373d3d3d363d73e o sufixo5af43d82803e903d91602b57fd5bf3. - Proxies de múltiplas assinaturas Safe mantêm sua implementação (o singleton, historicamente chamado de
masterCopy) no slot de armazenamento 0, não em um slot da EIP-1967. O roubo da Bybit em 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 de faceta diferente; você precisa da tabela de seletores para facetas no bloco.
Código-fonte verificado
Um explorador ou o repositório aberto Sourcify marca um contrato como verificado quando a compilação do código-fonte publicado com as configurações declaradas reproduz o bytecode implantado. O Sourcify distingue 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 comprova que o código-fonte corresponde ao bytecode. Ela não comprova 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 comportamento observado em rastros de execução, e reduzem seu grau de confiança de acordo com isso.
Multicall e chamadas em lote
O processamento em lote oculta ações por trá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 é a calldata para outra função do mesmo roteador.- Lotes de múltiplas assinaturas (MultiSend do Safe) e lotes de contas inteligentes (
handleOpsda ERC-4337, contas delegadas da EIP-7702).
O seletor externo diz apenas "lote". Decodifique recursivamente: decodifique a chamada externa, pegue cada elemento bytes interno, leia seu seletor e decodifique-o com a ABI do destino. Em seguida, confirme com o rastro de execução, a árvore de chamadas que mostra o destino, o valor e o sucesso de cada chamada interna, porque um lote pode permitir que chamadas individuais falhem. Os logs de todas as chamadas internas aparecem em um único recibo, ordenados por logIndex, que os vincula às etapas. A aula Consulta de dados UTXO e de logs e rastros de execução da 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 (primeiros 4 bytes de keccak-256, por exemplo, 0xa9059cbb para transfer) e leitura de palavras de calldata; colisões de seletores e como escolher entre candidatos de bases de dados; topic0 como o hash completo da assinatura do evento, limites de indexação, eventos anônimos e tópicos dinâmicos armazenados como hashes; proxies: os logs carregam o endereço do proxy, o slot de implementação da EIP-1967 (keccak-256 de "eip1967.proxy.implementation" menos 1), eth_getStorageAt em um bloco, eventos Upgraded, padrões beacon, mínimo e Safe no slot 0; o que a verificação comprova e o que não comprova; decodificação recursiva de lotes multicall e confirmação com rastros de execução.