Los eventos en Solidity son lo más parecido a una declaración print o console.log en Ethereum. Explicaremos cómo funcionan, las mejores prácticas para los eventos y profundizaremos en muchos detalles técnicos que a menudo se omiten en otros recursos.
Aquí hay un ejemplo mínimo para emitir un evento en Solidity.
contract ExampleContract {
// We will explain the significance of the indexed parameter later.
event ExampleEvent(address indexed sender, uint256 someValue);
function exampleFunction(uint256 someValue) public {
emit ExampleEvent(sender, someValue);
}
}
Quizás los eventos más conocidos son los emitidos por los tokens ERC20 cuando son transferidos. El remitente, el receptor y la cantidad quedan registrados en un evento.
¿No es esto redundante? Ya podemos revisar las transacciones pasadas para ver las transferencias, y luego podríamos examinar el calldata para ver la misma información.
Esto es correcto; se podrían eliminar los eventos y no tendría ningún efecto sobre la lógica de negocio del contrato inteligente. Sin embargo, esta no sería una forma eficiente de consultar el historial.
Recuperando transacciones más rápido
El cliente de Ethereum no tiene una API para listar transacciones por “tipo”. Estas son sus opciones si desea consultar transacciones históricas:
getTransactiongetTransactionFromBlock
getTransactionFromBlock solo puede indicarle qué transacciones ocurrieron en un bloque particular, no puede apuntar a contratos inteligentes a lo largo de múltiples bloques.
getTransaction solo puede inspeccionar transacciones de las cuales conoce el hash de transacción.
Los eventos, por otro lado, se pueden recuperar de forma mucho más sencilla. Estas son las opciones del cliente de Ethereum:
eventsevents.allEventsgetPastEvents
Cada una de estas requiere especificar la dirección del contrato inteligente que el consultante desea examinar, y devuelve un subconjunto (o todos) de los eventos que un contrato inteligente emitió de acuerdo con los parámetros de consulta especificados.
En resumen: Ethereum no proporciona un mecanismo para obtener todas las transacciones de un contrato inteligente, pero sí proporciona un mecanismo para obtener todos los eventos de un contrato inteligente.
¿Por qué es esto así? Hacer que los eventos sean rápidamente recuperables requiere una sobrecarga de almacenamiento adicional. Si Ethereum hiciera esto para cada transacción, esto haría que la cadena fuera considerablemente más grande. Con los eventos, los programadores de Solidity pueden ser selectivos sobre qué tipo de información vale la pena pagar la sobrecarga de almacenamiento adicional, para permitir una rápida recuperación off-chain.
Escuchando eventos
Los eventos están pensados para ser consumidos off-chain.
Aquí hay un ejemplo del uso de la API descrita anteriormente. En este código, el cliente se suscribe a los eventos de un contrato inteligente.
Ejemplo 1: Escuchando eventos Transfer de un ERC20.
Este código activa un callback cada vez que un token ERC20 emite un evento de transferencia (Transfer).
const { ethers } = require("ethers");
// const provider = your provider
const abi = [
"event Transfer(address indexed from, address indexed to, uint256 value)"
];
const tokenAddress = "0x...";
const contract = new ethers.Contract(tokenAddress, abi, provider);
contract.on("Transfer", (from, to, value, event) => {
console.log(`Transfer event detected: from=${from}, to=${to}, value=${value}`);
});
Ejemplo 2: Filtrando una aprobación (Approval) ERC20 para una dirección específica
Si queremos observar eventos de forma retroactiva, podemos usar el siguiente código. En este ejemplo, buscamos en el pasado transacciones Approval en un token ERC20.
const ethers = require('ethers');
const tokenAddress = '0x...';
const filterAddress = '0x...';
const tokenAbi = [
// ...
];
const tokenContract = new ethers.Contract(tokenAddress, tokenAbi, provider);
// this line filters for Approvals for a particular address.
const filter = tokenContract.filters.Approval(filterAddress, null, null);
tokenContract.queryFilter(filter).then((events) => {
console.log(events);
});
Si deseara buscar un intercambio entre dos direcciones particulares (si existe tal transacción), el código JavaScript de ethers.js sería el siguiente:
tokenContract.filters.Transfer(address1, address2, null);
El null en el código anterior significa “coincidir con cualquier valor para este campo”. Para el evento de transferencia, estamos buscando coincidencias con cualquier cantidad.
Aquí hay un ejemplo similar en web3.js. Note que se añaden los parámetros de consulta fromBlock y toBlock, y demostraremos la capacidad de escuchar a múltiples direcciones siendo el remitente. Las direcciones se combinan con la condición “OR”.
const Web3 = require('web3');
const web3 = new Web3('https://rpc-endpoint');
const contractAddress = '0x...'; // The address of the ERC20 contract
const contractAbi = [
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"name": "from",
"type": "address"
},
{
"indexed": true,
"name": "to",
"type": "address"
},
{
"indexed": false,
"name": "value",
"type": "uint256"
}
],
"name": "Transfer",
"type": "event"
}
];
const contract = new web3.eth.Contract(contractAbi, contractAddress);
const senderAddressesToWatch = ['0x...', '0x...', '0x...']; // The addresses to watch for transfers from
const filter = {
fromBlock: 0,
toBlock: 'latest',
topics: [
web3.utils.sha3('Transfer(address,address,uint256)'),
null,
senderAddressesToWatch,
]
};
contract.getPastEvents('Transfer', {
filter: filter,
fromBlock: 0,
toBlock: 'latest',
}, (error, events) => {
if (!error) {
console.log(events);
}
});
Las consultas por rangos no son posibles. No se puede especificar un filtro que diga “dame todas las transacciones donde la cantidad cae entre este límite inferior y ese límite superior”. Debe obtener todos los eventos y luego filtrarlos en el código del cliente.
Ejemplo 3: Creando una tabla de clasificación (leaderboard) sin almacenar las entradas
Considere un contrato inteligente con una función de donación, y desea que el frontend clasifique las donaciones por la cantidad entregada. Aquí hay una solución ineficiente:
contract Donations {
struct Donation {
address donator;
uint256 amount;
}
Donation[] public donations; // frontend queries this
fallback() external payable {
donations.push(Donation({
donator: msg.sender,
amount: msg.value
}));
}
// more functions for the owner to withdraw
}
Si las donaciones no necesitan ser leídas on-chain, esta es la solución ingenua, ya que aumentará significativamente el costo de gas para la persona que envía Ethereum al contrato.
Esta es una mejor solución usando eventos:
contract Donations {
event Donation(address indexed donator; uint256 amount);
fallback() external payable {
emit Donation(msg.sender, msg.value);
}
// more functions for the owner to withdraw
}
El frontend simplemente puede consultar al contrato inteligente por todos los eventos Donation y luego ordenarlos por el campo de la cantidad.
Los eventos se almacenan en el estado de la blockchain, no son efímeros. Por lo tanto, no hay necesidad de preocuparse de que el cliente “pierda” (missing) un evento. Simplemente vuelven a consultar los eventos para el contrato.
Eventos indexados vs eventos no indexados en Solidity
El ejemplo anterior funciona porque el evento Approve (y Transfer) en ERC20 establece que el remitente sea indexed. Aquí está la declaración en Solidity.
event Approval(address indexed owner, address indexed spender, uint256 value);
Si el argumento owner no estuviera indexado, el código javascript anterior fallaría silenciosamente. La implicación aquí es que no puede filtrar por eventos ERC20 que tengan un valor específico para la transferencia, porque eso no está indexado. Debe extraer todos los eventos y filtrarlos del lado de javascript; no se puede hacer en el cliente de Ethereum.
Un argumento indexado para una declaración de evento se llama topic.
Mejores prácticas para eventos en Solidity
La mejor práctica generalmente aceptada para los eventos es registrarlos siempre que ocurra un cambio de estado consecuente. Algunos ejemplos incluyen:
- Cambiar el propietario del contrato
- Mover ether
- Realizar un intercambio
No todo cambio de estado requiere un evento. La pregunta que los desarrolladores de Solidity deben hacerse es “¿tendría alguien interés en recuperar o descubrir esta transacción rápidamente?”
Indexe los parámetros de evento correctos
Esto requerirá cierto juicio subjetivo. Recuerde, un parámetro no indexado no se puede buscar directamente. Una buena forma de adquirir intuición para esto es observar cómo bases de código establecidas diseñan sus eventos
Como regla general, las cantidades de criptomonedas no deben estar indexadas, y una dirección (address) sí debería estarlo, pero esta regla no debe aplicarse a ciegas.
Evite eventos redundantes
Un ejemplo de esto sería añadir un evento cuando se acuñan tokens, porque las librerías subyacentes ya emiten este evento.
Los eventos no se pueden usar en funciones view
Los eventos cambian el estado; alteran el estado de la blockchain almacenando el log. Por lo tanto, no se pueden usar en funciones view (o pure).
Los eventos no son tan útiles para la depuración de la manera en que lo son console.log y print en otros lenguajes; debido a que los eventos en sí mismos cambian el estado, no se emiten si una transacción se revierte.
¿Cuántos argumentos puede tomar un evento?
Para argumentos no indexados, si usa demasiados argumentos alcanzará el límite del stack bastante rápido. El siguiente ejemplo sin sentido es válido en Solidity:
contract ExampleContract {
event Numbers(uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256);
}
De manera similar, no hay un límite intrínseco para la longitud de los strings o arrays almacenados en un log.
Sin embargo, no puede haber más de tres argumentos indexados (topics) en un evento. Un evento anónimo puede tener 4 argumentos indexados (cubriremos esta distinción más adelante).
Un evento con cero argumentos también es válido.
Los nombres de variables en los eventos son opcionales pero recomendados
Los siguientes eventos se comportan de forma idéntica
event NewOwner(address newOwner);
event NewOwner(address);
En general, incluir el nombre de la variable sería ideal porque la semántica detrás del siguiente ejemplo es muy ambigua
event Trade(address,address,address,uint256,uint256);
Podemos adivinar que las direcciones corresponden al remitente y a las direcciones del token, mientras que los uint256 corresponden a las cantidades, pero esto es difícil de descifrar.
Es convencional escribir con mayúscula inicial el nombre de un evento, pero el compilador no lo requiere.
Los eventos se pueden heredar a través de contratos padre e interfaces
Cuando un evento se declara en un contrato padre, puede ser emitido por el contrato hijo. Los eventos son internos y no pueden ser modificados para ser privados o públicos. Aquí hay un ejemplo
contract ParentContract {
event NewNumber(uint256 number);
function doSomething(uint256 number) public {
emit NewNumber(number);
}
}
contract ChildContract is ParentContract {
function doSomethingElse(uint256 number) public {
emit NewNumber(number);
}
}
De manera similar, los eventos pueden ser declarados en una interfaz y usados en el hijo, como en el siguiente ejemplo.
interface IExampleInterface {
event Deposit(address indexed sender, uint256 amount);
}
contract ExampleContract is IExampleInterface {
function deposit() external payable {
emit Deposit(msg.sender, msg.value);
}
}
Selector de evento
La EVM (Ethereum Virtual Machine) identifica los eventos con el keccak256 de su firma.
Para versiones de Solidity 0.8.15 o superiores, también puede recuperar el selector usando el miembro .selector.
pragma solidity ^0.8.15;
contract ExampleContract {
event SomeEvent(uint256 blocknum, uint256 indexed timestamp);
function selector() external pure returns (bool) {
// true
return SomeEvent.selector == keccak256("SomeEvent(uint256,uint256)");
}
}
El selector de evento es en realidad un topic en sí mismo (discutiremos esto más a fondo en una sección posterior).
Marcar las variables como indexadas o no, no cambia el selector.
Eventos anónimos
Los eventos se pueden marcar como anonymous, en cuyo caso no tendrán un selector. Esto significa que el código del lado del cliente no puede aislarlos específicamente como un subconjunto como en nuestros ejemplos anteriores.
pragma solidity ^0.8.15;
contract ExampleContract {
event SomeEvent(uint256 blocknum, uint256 timestamp) anonymous;
function selector() public pure returns (bool) {
// ERROR: does not compile, anonymous events don't have selectors
return SomeEvent.selector == keccak256("SomeEvent(uint256,uint256)");
}
}
Dado que la firma del evento se usa como uno de los índices, una función anónima puede tener cuatro topics indexados, ya que la firma de la función queda “liberada” como uno de los topics.
contract ExampleContract {
// valid
event SomeEvent(uint256 indexed, uint256 indexed, address indexed, address indexed) anonymous;
}
Los eventos anónimos rara vez se usan en la práctica.
Temas avanzados sobre eventos
Esta sección describe los eventos a nivel de assembly de la EVM. Esta sección puede ser omitida por programadores nuevos en el desarrollo blockchain.
Detalle de implementación: Filtros de Bloom
Para recuperar cada transacción que ha ocurrido con un contrato inteligente, el cliente de Ethereum tendría que escanear cada bloque, lo cual sería una operación de E/S extremadamente pesada; pero Ethereum utiliza una optimización importante.
Los eventos se almacenan en una estructura de datos de Filtro de Bloom para cada bloque. Un Filtro de Bloom es un conjunto probabilístico que responde eficientemente si un miembro está en el conjunto o no. En lugar de escanear el bloque completo, el cliente puede preguntar al Filtro de Bloom si se emitió un evento en el bloque; es mucho más rápido consultar un Filtro de Bloom que escanear todo el bloque.
Esto permite que el cliente busque en la blockchain mucho más rápido para encontrar eventos.
Los Filtros de Bloom son probabilísticos: a veces devuelven incorrectamente que un elemento es miembro del conjunto aunque no lo sea. Cuantos más miembros se almacenan en un Filtro de Bloom, mayor es la probabilidad de error, y más grande debe ser el Filtro de Bloom (en términos de almacenamiento) para compensar esto. Por esta razón, Ethereum no almacena transacciones en un Filtro de Bloom, solo eventos. Hay muchísimos menos eventos que transacciones. Esto mantiene manejable el tamaño de almacenamiento en la blockchain.
Cuando el cliente obtiene una respuesta positiva de membresía de un Filtro de Bloom, debe escanear el bloque para verificar que el evento tuvo lugar. Sin embargo, esto solo sucederá para un pequeño subconjunto de bloques, por lo que, en promedio, el cliente de Ethereum ahorra mucho cómputo al verificar primero la presencia de eventos en el Filtro de Bloom.
Eventos Yul (assembly de Solidity)
En la representación intermedia de Yul, la distinción entre argumentos indexados (topics) y argumentos no indexados se vuelve clara.
Las siguientes funciones de Yul están disponibles para emitir eventos (y su opcode de la EVM lleva el mismo nombre). La tabla está copiada de la documentación de Yul con alguna simplificación.
| op code | Uso |
|---|---|
| log0(p, s) | log sin topics y data en mem[p…(p+s)) |
| log1(p, s, t1) | log con topic t1 y data en mem[p…(p+s)) |
| log2(p, s, t1, t2) | log con topics t1, t2 y data en mem[p…(p+s)) |
| log3(p, s, t1, t2, t3) | log con topics t1, t2, t3 y data en mem[p…(p+s)) |
| log4(p, s, t1, t2, t3, t4) | log con topics t1, t2, t3, t4 y data en mem[p…(p+s)) |
Un log puede tener hasta 4 topics, pero un evento en Solidity no anónimo puede tener hasta 3 argumentos indexados. Esto se debe a que el primer topic se usa para almacenar la firma del evento. No hay ningún opcode ni función de Yul para emitir más de cuatro topics.
Los parámetros no indexados simplemente están codificados en ABI en la región de memoria [p…(p+s)) y se emiten como una secuencia larga de bytes.
Recuerde anteriormente que no había límite en principio para cuántos argumentos no indexados podía tener un evento en Solidity. La razón subyacente es que no hay un límite explícito sobre cuánto ocupa la región de memoria apuntada en los dos primeros parámetros del opcode de log. Existen, por supuesto, límites provistos por el tamaño del contrato y los costos de gas por expansión de memoria.
Costo de gas para emitir un evento en Solidity
Los eventos son sustancialmente más baratos que escribir en variables de almacenamiento. Los eventos no están destinados a ser accesibles por los contratos inteligentes, por lo que la relativa falta de sobrecarga justifica un menor costo de gas.
La fórmula para calcular cuánto gas cuesta un evento es la siguiente (fuente):
375 + 375 * num_topics + 8 * data_size + mem_expansion_cost
Cada evento cuesta al menos 375 de gas. Se pagan 375 adicionales por cada parámetro indexado. Un evento no anónimo tiene el selector de evento como un parámetro indexado, por lo que ese costo está incluido la mayor parte del tiempo. Luego pagamos 8 veces el número de palabras de 32 bytes escritas en la cadena. Dado que esta región se almacena en memoria antes de ser emitida, el costo de expansión de la memoria también debe ser contabilizado.
El factor más significativo en el costo de gas de un evento es el número de eventos indexados, por lo que no indexe las variables si no es necesario.
Conclusión
Los eventos sirven para que los clientes recuperen rápidamente las transacciones que puedan ser de interés. Aunque no alteran la funcionalidad del contrato inteligente, permiten al programador especificar qué transacciones deben ser rápidamente recuperables. Esto es importante para mejorar la transparencia en los contratos inteligentes.
Los eventos son relativamente baratos en cuanto a gas comparado con otras operaciones, pero el factor más importante en su costo es el número de parámetros indexados, asumiendo que el programador no usa una cantidad desmedida de memoria.
Aprenda más
¿Le gusta lo que ve aquí? Vea nuestro Solidity Bootcamp para aprender más.
También tenemos un tutorial de Solidity gratuito para ayudarle a empezar.
Publicado originalmente el 1 de abril de 2023