Consultar blockchains com SQL · Aula 5 de 6
Consultas a logs e traces da EVM
10 min de leitura
Nas redes EVM, a maior parte do que interessa a um analista (movimentações de tokens, swaps, aprovações, empréstimos) não fica registrada na própria transação, mas nos logs de eventos que ela emite e nas chamadas internas que realiza. Tabelas decodificadas abrangem contratos populares, mas, mais cedo ou mais tarde, um caso envolve um token novo, um contrato não verificado ou um padrão que ninguém decodificou. Esta aula mostra como filtrar, decodificar e agregar logs e traces brutos em SQL, e como medir o gas.
As consultas usam o esquema genérico da aula Fundamentos de SQL para tabelas de blockchain: logs (contract_address, topic0 a topic3, data), traces (trace_address, trace_type, call_type, from_address, to_address, value, status, tx_status), transactions e blocks.
Assinaturas de eventos e topic0
Um log tem três partes: o endereço do contrato que o emitiu, até quatro topics e um campo data (veja a aula Transações, recibos e logs brutos do Ethereum). Para um evento Solidity normal, topic0 é o hash Keccak-256 da assinatura canônica do evento: o nome e os tipos dos parâmetros, sem espaços nem nomes de parâmetros. Os parâmetros declarados como indexed vão para topic1 a topic3, e os demais são codificados em ABI no campo data como palavras de 32 bytes.
| Evento | Assinatura canônica | topic0 |
|---|---|---|
| Transferência ERC-20 e ERC-721 | Transfer(address,address,uint256) | 0xddf252ad…523b3ef |
| Aprovação ERC-20 | Approval(address,address,uint256) | 0x8c5be1e5…8c7c3b925 |
| Transferência única ERC-1155 | TransferSingle(address,address,address,uint256,uint256) | 0xc3d58168…aa2d0f62 |
| Depósito de ether encapsulado | Deposit(address,uint256) | 0xe1fffcc4…5cc9109c |
Filtrar por topic0 seleciona um tipo de evento em todos os contratos. Inclua contract_address sempre que estiver se referindo a um token ou protocolo específico: qualquer pessoa pode implantar um contrato que emita um evento Transfer perfeitamente formado, e tokens fraudulentos fazem exatamente isso para inserir transferências falsas no histórico das vítimas. Um evento só é tão confiável quanto o contrato que o emitiu.
ERC-20 e ERC-721 compartilham a mesma assinatura Transfer e, portanto, o mesmo topic0. A diferença está na indexação: ERC-721 indexa o ID do token, então suas transferências têm quatro topics, enquanto transferências ERC-20 têm três topics e incluem o valor em data. Testar topic3 IS NULL permite separá-los.
Conjuntos de dados reais costumam armazenar os topics em um array, em vez de quatro colunas, e a indexação de arrays varia entre os mecanismos: o primeiro topic é topics[OFFSET(0)] (ou SAFE_OFFSET(0)) no BigQuery, que conta a partir de zero, mas é topics[1] no PostgreSQL, Trino e DuckDB, que contam a partir de um. Se isso for feito incorretamente, todos os topics ficam deslocados em uma posição, sem que seja gerado um erro.
Decodificação de topics e data
Topics e palavras de dados têm 32 bytes, representados por 64 caracteres hexadecimais após 0x. Um endereço ocupa os últimos 20 bytes (40 caracteres), preenchidos com zeros à esquerda, então começa no caractere 27 da string do topic. Uma palavra de dados k (contando a partir de zero) começa no caractere 3 + 64k.
-- Decodifica eventos Transfer de ERC-20 de um token a partir de logs brutos
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 -- primeira palavra de 32 bytes
FROM logs
WHERE contract_address = '0x7a11…0001'
AND topic0 = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
AND topic3 IS NULL -- ERC-721 adiciona um quarto topic (tokenId)
AND block_number BETWEEN 21000000 AND 21000999;
A consulta filtra por contrato, evento e quantidade de topics, e depois extrai partes das strings. Em uma linha de exemplo, data termina em 77359400, que corresponde a 2.000.000.000 em decimal; com as 6 casas decimais do token, isso equivale a 2.000 tokens.
Converter a palavra hexadecimal em número é onde os mecanismos mais diferem:
| Mecanismo | Conversão | Limite |
|---|---|---|
| DuckDB | CAST(CONCAT('0x', h) AS HUGEINT) | Inteiro com sinal de 128 bits |
| Trino | from_base(h, 16) | BIGINT de 64 bits: falha acima de aproximadamente 9,22 × 10^18 |
| BigQuery | CAST(CONCAT('0x', h) AS INT64) para valores pequenos; uma função definida pelo usuário para valores maiores | INT64 de 64 bits |
| PostgreSQL | Não há uma função integrada para valores grandes; use uma função definida pelo usuário | Depende da função |
O limite de 64 bits é a armadilha: 9,22 × 10^18 unidades brutas equivalem a apenas 9,22 tokens com 18 casas decimais, então conversões que funcionam com dados de teste falham ou sofrem overflow em transferências reais. Para qualquer coisa além de uma verificação rápida, decodifique os valores na etapa de ETL ou use uma tabela decodificada e armazene o resultado como um tipo decimal ou de número grande. Tipos dinâmicos (strings, arrays) são codificados como deslocamentos e comprimentos e é melhor deixá-los para uma biblioteca de decodificação ABI.
Alguns valores especiais podem ser testados sem conversão. Uma aprovação "ilimitada" define a permissão como 2^256 − 1, que corresponde a 64 caracteres f:
-- Aprovações ilimitadas de tokens: allowance = 2^256 - 1 (64 'f' hexadecimais)
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;
A comparação de strings evita qualquer operação aritmética de 256 bits. Listas de proprietários que concederam aprovações ilimitadas a um único spender são uma etapa padrão ao avaliar o alcance de uma campanha de drenagem de carteiras ou de um protocolo comprometido (veja a aula Análise de explorações e ataques).
Transferências internas a partir de traces
ETH enviado por um contrato, por exemplo, um saque de um pool ou um pagamento de uma multisig, não aparece em transactions.value. Ele aparece em traces: uma linha por quadro de chamada, gerada pela reexecução da transação. Colunas principais:
trace_address: a posição do quadro na árvore de chamadas. Neste esquema, é texto:''para a chamada de nível superior,'0'para sua primeira subchamada,'0,1'para a segunda chamada dentro dessa subchamada. Alguns conjuntos de dados armazenam esse valor como um array, em que a chamada de nível superior tem um array vazio.trace_type:call,create(implantação de contrato),suicide(o nome histórico de selfdestruct) oureward.call_type: para chamadas,call,delegatecalloustaticcall.status(este quadro foi concluído com sucesso) etx_status(a transação inteira foi concluída com sucesso).
-- Transferências internas de ETH (enviadas por contratos) para um endereço
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 não transferem ETH
AND trace_address <> '' -- '' é a própria chamada de nível superior
AND status = 1 -- este quadro de chamada foi concluído com sucesso
AND tx_status = 1 -- e a transação também
AND block_number BETWEEN 21000000 AND 21050000;
Cada filtro remove um falso positivo conhecido. Uma staticcall não pode transferir valor, e uma delegatecall executa o código de outro contrato no contexto do chamador sem transferir ETH, embora alguns formatos de trace mostrem um valor que nunca foi transferido. Excluir o quadro de nível superior evita contar duas vezes o valor da própria transação se você também consultar transactions. O status importa mais para traces do que para logs: uma transação revertida não emite logs, mas seus traces ainda são registrados, e um quadro pode ser revertido dentro de uma transação que, no geral, é bem-sucedida. Os efeitos de um quadro só persistem se ele e todos os quadros acima dele forem bem-sucedidos; exigir status e tx_status cobre os casos usuais, mas uma reversão capturada por um contrato no meio da árvore ainda pode passar despercebida, então verifique individualmente transações incomuns.
Para medir todo o ETH recebido por um endereço, uma regra mais simples é usar apenas traces, incluindo o quadro de nível superior, em vez de somar transações e traces. Lembre-se de que quadros create com valor e quadros selfdestruct também transferem ETH.
Análise de gas
Desde o EIP-1559 (agosto de 2021), a taxa de cada transação é dividida entre uma taxa base queimada e uma taxa de prioridade paga ao produtor do bloco:
- taxa paga = gas usado × preço efetivo do gas
- valor queimado = gas usado × taxa base por unidade de gas (do bloco)
- taxa de prioridade = gas usado × (preço efetivo do gas − taxa base)
Transações que carregam blobs (EIP-4844) pagam uma taxa separada por blob, calculada como gas de blob usado × preço do gas de blob, que também é queimada e registrada em campos próprios do recibo.
-- Taxas diárias, valores queimados e taxas de prioridade, em 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 -- transações malsucedidas também pagam taxas
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;
Diferentemente das consultas de valor, esta mantém deliberadamente as transações malsucedidas, porque elas consomem gas e pagam por ele. A multiplicação de gas por um preço em wei facilmente ultrapassa 64 bits (10 milhões de gas a 1.000 gwei equivalem a 10^19 wei), outro motivo pelo qual o esquema armazena essas colunas como decimais. Em uma amostra pequena, um dia apresenta 0,0239 ETH em taxas, dos quais apenas 0,0030 ETH foram queimados: uma única transação que paga 1.000 gwei por unidade de gas (uma taxa de prioridade de 980 gwei acima da taxa base) domina o total, o tipo de valor atípico que merece uma análise mais cuidadosa.
Os dados de gas dão suporte a várias perguntas analíticas: qual endereço financiou o gas de uma conta (um vínculo forte; veja a aula Heurísticas do modelo de contas), quais remetentes pagam taxas de prioridade excepcionalmente altas (muitas vezes bots ou invasores com urgência de tempo) e quanto custou executar um ataque. Em rollups, os recibos incluem campos adicionais para a taxa de dados de L1, e os nomes desses campos variam de acordo com a rede.
Foco no exame: topic0 como hash Keccak-256 da assinatura canônica do evento, com parâmetros indexados em topics e os demais em palavras de dados de 32 bytes; filtragem pelo endereço do contrato para evitar eventos falsificados; distinção entre transferências ERC-20 e ERC-721 pela quantidade de topics; arrays de topics indexados a partir de zero versus a partir de um, e extração de um endereço com SUBSTR(topic, 27, 40) e de palavras de dados na posição 3 + 64k; a armadilha de overflow de 64 bits na conversão de valores; identificação de aprovações ilimitadas; filtragem de traces por tipo de chamada, endereço do trace e status, e por que transações revertidas têm traces, mas não logs; fórmulas de taxas, valores queimados e taxas de prioridade do EIP-1559, e inclusão de transações malsucedidas nos totais de taxas.