Ethereum: de historia a labs

Artículo 11 de 48

23%

completado al llegar aquí

Sistemas distribuidosNota de investigación12 min de lectura

ABI, calldata y eventos: el idioma público de los contratos

Cómo se calcula un selector de 4 bytes, cómo se codifican argumentos en palabras de 32 bytes, qué guardan topics y data de un evento y cómo leer un revert.

Ruta Ethereum: 0/48

Prerequisitos recomendados

Si alguno de estos conceptos todavía está medio nebuloso, empieza por aquí. Te ahorra tropiezos y un par de cejas fruncidas.

Un usuario abre su wallet para aprobar un pago y ve un campo de datos que empieza por 0xa9059cbb seguido de 128 caracteres hexadecimales. ¿Qué está autorizando? Responder exige conocer la ABI (Application Binary Interface), el formato que define cómo se convierten llamadas, resultados, eventos y errores en bytes. Este capítulo la recorre con un ejemplo concreto: una transferencia ERC-20.

La EVM no conoce funciones ni tipos: recibe bytes. Que un contrato tenga una función transfer es una convención entre el compilador y quien lo llama, y esa convención está escrita en la especificación ABI de Solidity.

Brújula de lectura

Actor, evidencia y estado

Antes de avanzar, ubica quién actúa, qué prueba queda y qué parte del sistema cambia.

Actor

Frontend, wallet e indexador

Codifican llamadas y decodifican resultados y eventos con el mismo esquema.

Evidencia

Calldata, logs y datos de revert

Bytes públicos que solo tienen sentido con la ABI correcta.

Estado

Ninguno por sí mismo

La ABI describe la frontera; el estado lo cambia el código que recibe los bytes.

Preguntas guía

  • ¿Cómo sabe un contrato qué función quiere ejecutar quien lo llama?
  • ¿Por qué cada argumento ocupa 32 bytes aunque sea una dirección de 20?
  • ¿Qué parte de un evento se puede filtrar y qué parte solo se puede leer?
  • ¿Qué información trae una transacción que revierte y cuánto se puede confiar en ella?

Conexiones dentro de la serie

El selector: cuatro bytes de un hash

Los primeros cuatro bytes de la calldata eligen la función. Son los cuatro primeros bytes del Keccak-256 de la firma canónica: el nombre de la función y la lista de tipos de sus parámetros entre paréntesis, separados por comas y sin espacios. El tipo de retorno no forma parte de la firma.

Para transfer(address,uint256) el hash empieza por a9059cbb, que es exactamente el prefijo que vio el usuario. La firma usa tipos canónicos: uint se escribe uint256. Un espacio de más o un alias cambia el hash y produce un selector que ningún contrato reconoce.

Cuatro bytes son 2322^{32} valores posibles, así que dos funciones distintas pueden compartir selector. La documentación de ethereum.org lo muestra con el mismo 0xa9059cbb: las bases de datos públicas de firmas registran varias funciones con ese selector, y solo el código fuente verificado del contrato dice cuál es la real. Una herramienta que traduce selectores a nombres está adivinando, no verificando.

Traza de codificación: palabras de 32 bytes, cabeza y cola

Después del selector vienen los argumentos, codificados en palabras de 32 bytes. Para la llamada transfer(0x70997970C51812dc3A010C7d01b50e0d17dc79C8, 10^18) la calldata completa son 68 bytes:

Texto
a9059cbb                                                           selector
00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8   to (address)
0000000000000000000000000000000000000000000000000de0b6b3a7640000   amount = 10^18

Las direcciones y los enteros sin signo se rellenan con ceros a la izquierda hasta 32 bytes; los tipos bytesN de tamaño fijo se rellenan a la derecha. Así, 0x0de0b6b3a7640000 es 101810^{18} en hexadecimal: un token con 18 decimales lo mostraría como 1,0.

Los tipos dinámicos, como string, bytes o arrays sin tamaño fijo, no caben en una palabra. La ABI los resuelve en dos zonas. En la cabeza, cada argumento ocupa una palabra: los estáticos van completos y los dinámicos dejan un desplazamiento en bytes. En la cola, cada dinámico se escribe con su longitud seguida de su contenido. La especificación desarrolla el ejemplo completo de f(uint256,uint32[],bytes10,bytes), cuyo selector es 0x8be65246.

La codificación no se describe a sí misma. Los mismos 64 bytes de argumentos se pueden leer como (address,uint256) o como (uint256,uint256) sin ningún error: quien decodifica necesita la ABI correcta, y un decodificador con la ABI equivocada produce números válidos pero falsos.

Figura 1

Anatomía de una llamada transfer y de su evento Transfer

Fuentes:

[1][3]
Anatomía de una llamada transfer y de su evento Transfer
Cómo leer esta figura

Fila superior: una barra de calldata dividida en tres tramos. El primero, a9059cbb, es el selector de 4 bytes tomado del keccak de la firma. El segundo es la palabra 1, una dirección rellenada con ceros a la izquierda que termina en 70997970 y 17dc79c8. El tercero es la palabra 2, un uint256 igual a 10 a la 18, 0de0b6b3a7640000, también rellenado a la izquierda. Fila inferior: cinco cajas del log del evento Transfer: address, el contrato emisor; topics 0, que empieza por ddf252ad y es el keccak de la firma del evento; topics 1, from, y topics 2, to, parámetros indexed que se pueden filtrar; y data, value, con los parámetros no indexados codificados con ABI.

Visual original de TrautsLab. Arriba, la calldata: selector de 4 bytes y dos palabras de 32 bytes rellenas a la izquierda. Abajo, el log que emite el token: la dirección del contrato, la firma del evento en topics[0], los dos parámetros indexed en topics[1] y topics[2] y el valor en data.

Eventos: topics para buscar, data para leer

Cuando el token ejecuta la transferencia, ERC-20 exige emitir Transfer(address indexed _from, address indexed _to, uint256 _value). Ese evento se convierte en un log con tres partes: la dirección del contrato que lo emitió, hasta cuatro topics de 32 bytes y un campo de datos.

Las reglas de codificación son estas:

  • topics[0] es el Keccak-256 de la firma del evento, salvo que el evento se declare anonymous. Para Transfer(address,address,uint256) vale 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef.
  • Los parámetros marcados indexed ocupan los topics siguientes: hasta tres en un evento normal y cuatro en uno anónimo.
  • Los parámetros no indexados se codifican con ABI en data.

Los topics existen para que un nodo pueda filtrar sin decodificar: "dame todos los Transfer hacia esta dirección" es una búsqueda por topics[0] y topics[2]. El costo de esa comodidad aparece con los tipos dinámicos. Si un string o un array se marca indexed, el topic guarda su Keccak-256 y no el valor, de modo que se puede buscar un valor conocido pero no recuperar uno desconocido.

Recuerda la frontera del capítulo anterior: los logs no son estado y ningún contrato puede leerlos. Un evento informa a observadores externos; no reemplaza la consulta al storage cuando se necesita el valor vigente.

Errores: el revert también habla ABI

Cuando una llamada revierte, puede devolver datos que explican el fallo, codificados igual que una llamada a función. Solidity usa tres formas:

  • Error(string), con selector 0x08c379a0, para require(cond, "mensaje") y revert("mensaje").
  • Panic(uint256), con selector 0x4e487b71, para fallos que indican un bug. Algunos códigos: 0x01 por un assert falso, 0x11 por desbordamiento aritmético fuera de un bloque unchecked, 0x12 por división entre cero y 0x32 por índice de array fuera de rango.
  • Errores personalizados, como InsufficientBalance(uint256,uint256), cuyo selector 0xcf479181 se calcula igual que el de una función.
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
// Ilustrativo: un token mínimo que muestra un evento y un error personalizado.
contract MiniToken {
    mapping(address => uint256) public balanceOf;
    event Transfer(address indexed from, address indexed to, uint256 value);
    error InsufficientBalance(uint256 available, uint256 required);
 
    function transfer(address to, uint256 amount) external returns (bool) {
        uint256 available = balanceOf[msg.sender];
        if (available < amount) revert InsufficientBalance(available, amount);
        balanceOf[msg.sender] = available - amount;
        balanceOf[to] += amount;
        emit Transfer(msg.sender, to, amount);
        return true;
    }
}

La especificación incluye una advertencia que conviene copiar en cualquier guía interna: nunca confíes en los datos de error. Se propagan a través de llamadas anidadas, así que un contrato puede recibir un error que no definió ninguno de los contratos a los que llamó, y cualquier contrato puede fabricar datos que imitan la firma de un error ajeno.

Riesgo operativo: decodificar sin confiar a ciegas

Leer bien la calldata cambia decisiones de seguridad. Un transfer mueve tokens una vez. Un approve(spender, value), con selector 0x095ea7b3, autoriza a spender a retirar de tu cuenta varias veces hasta value. Los dos se parecen en la pantalla de una wallet que muestra solo bytes, pero el segundo deja un permiso abierto después de la firma. Una política de firma debe distinguirlos por selector y mostrar el spender y el monto antes de aprobar.

La ABI es pública y cualquiera puede imitarla. De ahí salen los riesgos más comunes en producción:

Lista de verificación

Controles para quien decodifica calldata y eventos

0 de 5 revisados

Los dos últimos puntos tienen respaldo directo. El receipt de una transacción con status 0 no trae logs, y la interfaz JSON-RPC marca un log con removed: true cuando una reorganización de la cadena lo elimina. La descripción de ERC-7730 y del firmado a ciegas está en la documentación de transacciones de ethereum.org.

Laboratorio de decodificación con cast

Estos comandos no necesitan nodo: trabajan solo con la ABI y Keccak-256.

Bash
# Selector de una función y topic de un evento
cast sig "transfer(address,uint256)"                          # 0xa9059cbb
cast sig-event "Transfer(address,address,uint256)"            # 0xddf252ad...b3ef
 
# Codificar la llamada completa y volver a decodificarla
cast calldata "transfer(address,uint256)" 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 1000000000000000000
cast decode-calldata "transfer(address,uint256)" 0xa9059cbb00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000000000000000000000000000000de0b6b3a7640000
 
# Selector de un error personalizado
cast sig "InsufficientBalance(uint256,uint256)"               # 0xcf479181

Qué observar: cast calldata debe producir los 68 bytes de la traza anterior. Prueba a decodificar los mismos bytes con "approve(address,uint256)": cast los aceptará, porque la ABI no se describe a sí misma, aunque el selector real sea el de transfer. Esa es la razón por la que la ABI se toma del contrato verificado y no de una suposición.

Referencias

Bibliografía académica

Formato IEEENumerado para ingeniería, sistemas y protocolos.
  1. [1]

    Solidity Team, "Contract ABI specification," Solidity documentation.

    formal specID: SOLIDITY-ABI-SPECAbrir fuente (abre en otra pestaña)

    Selector, codificación de argumentos, eventos y errores.

  2. [2]

    ethereum.org, "Transactions," Ethereum developer documentation. [Online]. Available: https://ethereum.org/developers/docs/transactions/

    official docsID: ETH-TRANSACTIONS-DOCSAbrir fuente (abre en otra pestaña)

    Campos de una transacción, tipos y ciclo de vida.

  3. [3]

    F. Vogelsteller and V. Buterin, "ERC-20: Token standard," Ethereum Improvement Proposals, 2015.

    Funciones transfer y evento Transfer.

  4. [4]

    Solidity Team, "Contracts: events," Solidity documentation.

    official docsID: SOLIDITY-EVENTSAbrir fuente (abre en otra pestaña)

    Logs inaccesibles desde contratos y hasta tres parámetros indexed.

  5. [5]

    Solidity Team, "Expressions and control structures: error handling," Solidity documentation.

    official docsID: SOLIDITY-ERRORSAbrir fuente (abre en otra pestaña)

    Error(string), Panic(uint256) y códigos de pánico.

  6. [6]

    ethereum.org, "JSON-RPC API," Ethereum developer documentation. [Online]. Available: https://ethereum.org/developers/docs/apis/json-rpc/

    official docsID: ETH-JSON-RPC-DOCSAbrir fuente (abre en otra pestaña)

    Codificación hex de cantidades y datos, parámetro de bloque y ejemplos curl.

Fuentes primarias, papers seminales y especificaciones técnicas citadas en esta investigación.

Rutas vecinas

Si este tema te abrió hambre, sigue por aquí.

Series y labs que comparten área o dependen de esta ruta. Pocas opciones, para no convertir la curiosidad en menú infinito.

Sigue leyendo

Ideas conectadas con este artículo

Una ruta corta para continuar sin abrir veinte pestañas y perder el hilo.

Los comentarios se cargan al final de la lectura para no meter peso antes de tiempo.