Actualización de mediados de 2024: A partir de la actualización de Dencun, la optimización de calldata no tiene tanto impacto ya que las transacciones en la mayoría de las L2 se almacenan en blobs, en lugar de calldata. Mantenemos este artículo con fines históricos.
Al desarrollar aplicaciones en una L2, la mayor parte de los costos de gas provienen del calldata. Por lo tanto, la optimización de gas para L2 enfatiza minimizar ese costo.
Este artículo explora cómo funciona la optimización de calldata, proporciona algunos ejemplos y discute técnicas específicas para cada cadena.
Requisitos previos
- El lector debe estar familiarizado con Solidity y la Ethereum Virtual Machine (EVM).
- El lector debe conocer al menos algunas técnicas sencillas de optimización de gas.
- El lector debe saber qué es la codificación/decodificación ABI; este video sobre codificación ABI es un buen punto de partida para aprender.
Autoría
Este artículo fue escrito por Rati Montreewat (LinkedIn, Twitter), ingeniero blockchain, autor de Solid Grinder, una herramienta de optimización de calldata para L2, y exalumno del Solidity Bootcamp de RareSkills.
El costo del calldata
Ethereum cobra por cada byte de calldata, Gtxdatazero por un byte cero y Gtxdatanonzero por un byte distinto de cero, que son 4 gas y 16 gas respectivamente, como se muestra en el yellowpaper:

Las Capas 2 publican el calldata en la capa 1, por lo que deben pagar el costo de calldata de la capa 1. Además, la capa 2 impone una “tarifa de seguridad” adicional.
Matemáticamente, el gas total de la transacción de capa 2 se define como:

El gas de la L1 a menudo puede representar entre el 90% y el 99% del costo total de gas (gas L1 + gas L2). Cabe señalar que estas cifras dependen en gran medida de la congestion de la red en la L1.
Reglas diferentes para cadenas L2 diferentes
Aunque es cierto que la mayor parte del gas gastado en las L2 proviene de la parte de datos/seguridad, el mismo conjunto de smart contracts en diferentes L2 podría producir resultados de gas diferentes. Esto se debe a que las diferentes cadenas L2 (como Arbitrum, Optimism, Starknet, etc.) usan reglas y fórmulas diferentes para calcular cuánto cobrarán al usuario por el calldata además del costo de la L1. Por lo tanto, si un método de optimización de gas produce el resultado óptimo en una cadena L2, no significa que también producirá el mismo resultado óptimo en otras cadenas L2.
Además, estas reglas han ido evolucionando a medida que el cliente y el ecosistema de Ethereum maduran con el tiempo. A modo de ejemplo, el EIP4844 (también conocido como Proto-Danksharding) hará que el componente de gas de datos/seguridad de las L2 sea aún más barato y que la parte de ejecución de la L2 sea más significativa, lo que resultará en posibles cambios sobre cómo se calculará la tarifa de ejecución de la L2 para reflejar el modelo económico y de incentivos adecuado.
Así es como se calcula el gas de transacción de diferentes L2:
Arbitrum
A continuación se muestra la fórmula que usa Arbitrum para calcular el costo de gas de una transacción:

La ExecutionFee se calcula de manera similar a cómo se computan las transacciones en una cadena EVM, excepto que está sujeta a un PriceFloor.
Arbitrum intenta comprimir el calldata usando el algoritmo Brotli antes de publicarlo en la L1.
Optimism
Optimism tiene un modelo ligeramente diferente para el cobro del calldata:

Puedes pensar en los términos subrayados en azul como lo que cobra Ethereum y en los términos subrayados en rojo como el margen de beneficio de Optimism.
Métodos para optimizar el calldata
El factor clave para determinar la cantidad de gas requerida para el componente de calldata es el tamaño del calldata, y esto está especificado por la regla de codificación ABI. En particular, la ABI (Application Binary Interface), de acuerdo con la Documentación Oficial de Solidity.
La mejor manera de tener una intuición sobre el formato del calldata es con un ejemplo.
Primero, instalemos cast, un kit de herramientas para interactuar con la EVM, y usaremos Foundryup como instalador de la cadena de herramientas (toolchain):
curl -L https://foundry.paradigm.xyz | bash
foundryup
Luego usamos el siguiente comando de cast que muestra cómo Solidity codifica la función con argumentos:
cast calldata "addLiquidity(address,address,uint256,uint256,uint256,uint256,address,uint256)" 0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD 0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD 1200000000000000000000 2500000000000000000000 1000000000000000000000 2000000000000000000000 0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD 100
El resultado tiene un conteo total de 520 bytes hexadecimales = 520/2 = 260 bytes:
0xe8e33700000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead0000000000000000000000000000000000000000000000410d586a20a4c000000000000000000000000000000000000000000000000000878678326eac90000000000000000000000000000000000000000000000000003635c9adc5dea0000000000000000000000000000000000000000000000000006c6b935b8bbd400000000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead0000000000000000000000000000000000000000000000000000000000000064
Como puedes ver, los primeros 4 bytes del calldata son los primeros cuatro bytes del hash Keccak256 de la firma de la función (addLiquidity(address,…)). Después del selector de función, los siguientes fragmentos de 32 bytes son los argumentos de la función. Si el argumento es menor a 32 bytes, de forma predeterminada se rellena a la izquierda con ceros adicionales para que encaje dentro de los 32 bytes.
Para ilustrarlo, los fragmentos de calldata se pueden dividir de la siguiente manera:
- 0xe8e33700 como selector de función
- 000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead como address de 0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD
- 000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead como address de 0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD
- 0000000000000000000000000000000000000000000000410d586a20a4c00000 como uint256 de 1200000000000000000000
- 0000000000000000000000000000000000000000000000878678326eac900000 como uint256 de 2500000000000000000000
- 00000000000000000000000000000000000000000000003635c9adc5dea00000 como uint256 de 1000000000000000000000
- 00000000000000000000000000000000000000000000006c6b935b8bbd400000 como uint256 de 2000000000000000000000
- 000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead como address de 0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD
- 0000000000000000000000000000000000000000000000000000000000000064 como uint256 de 64
Existen varias técnicas para reducir el total de bytes del calldata, sin perder información. El concepto es intentar codificar el calldata de manera compacta para utilizar la menor cantidad posible de bytes de calldata. Posteriormente, los datos codificados se decodifican en un formato utilizable.
La sobrecarga de descomprimir el calldata suele ser insignificante en comparación con el gas ahorrado al comprimir el calldata.
Los trucos que se discuten aquí no funcionarán en todos los contextos posibles. La cantidad de bytes ahorrados depende en gran medida de la lógica de negocio específica en el smart contract.
Omitiendo los bytes de uso más frecuente
Si conocemos el valor exacto ya sea de la firma de la función o del parámetro de la función, podemos codificarlos de forma fija (hard-code) como constantes para usarlos más tarde cuando sea necesario. Como tenemos un conjunto limitado de funciones, por ejemplo, no necesitamos los cuatro bytes completos para identificarlas.
Podemos diseñar el conjunto de smart contracts utilizando el patrón de fábrica (factory pattern) de manera que la fábrica despliegue un contrato único con cada combinación de métodos y parámetros. A continuación se proporcionan algunos ejemplos de optimización de gas:
Firma de función
Podemos ahorrar 4 bytes de calldata utilizando solo la función fallback en el contrato:
fallback() external payable {
// business logic
}
Parámetros de función
Podemos ahorrar 32 bytes de calldata eliminando un parámetro de la función.
Por ejemplo, la address del contrato ERC20 se puede definir de forma fija (hardcoded) como constante y podría eliminarse de la función. Esto puede ahorrar un total de 20 bytes distintos de cero (igual que el tamaño de address) y 12 bytes cero (bytes de relleno hasta completar los 32 bytes).
address public constant USDC = <address>;
function TEST() external {
// business logic using USDC
}
Si tienes curiosidad y te gustaría explorar más sobre la implementación en la práctica, puedes revisar los siguientes proyectos con diseños interesantes:
Almacenamiento en caché de direcciones usando una tabla de direcciones
Una AddressTable se puede pensar como una base de datos en caché que almacena direcciones previamente registradas utilizando un id.
Por ejemplo, el usuario registra la dirección primero, luego la dirección se mapea automáticamente a un id. Más tarde, el usuario simplemente puede usar el id en lugar de la dirección completa. Esto resulta en una gran reducción del tamaño del calldata, pasando de 20 bytes a solo unos pocos bytes.
Detrás de escena, la tabla es simplemente un smart contract que almacena el mapeo entre direcciones e índices. También tiene la funcionalidad de buscar la dirección registrada utilizando el id mapeado correspondiente.
Este diseño fue adoptado e implementado por Arbitrum. La interfaz es la siguiente:
interface ArbAddressTable {
/**
* @notice Check whether an address exists in the address table
* @param addr address to check for presence in table
* @return true if address is in table
*/
function addressExists(address addr) external view returns (bool);
/**
* @notice compress an address and return the result
* @param addr address to compress
* @return compressed address bytes
*/
function compress(address addr) external returns (bytes memory);
/**
* @notice read a compressed address from a bytes buffer
* @param buf bytes buffer containing an address
* @param offset offset of target address
* @return resulting address and updated offset into the buffer (revert if buffer is too short)
*/
function decompress(bytes calldata buf, uint256 offset)
external
view
returns (address, uint256);
/**
* @param addr address to lookup
* @return index of an address in the address table (revert if address isn't in the table)
*/
function lookup(address addr) external view returns (uint256);
/**
* @param index index to lookup address
* @return address at a given index in address table (revert if index is beyond end of table)
*/
function lookupIndex(uint256 index) external view returns (address);
/**
* @notice Register an address in the address table
* @param addr address to register
* @return index of the address (existing index, or newly created index if not already registered)
*/
function register(address addr) external returns (uint256);
/**
* @return size of address table (= first unused index)
*/
function size() external view returns (uint256);
}
Sin embargo, la implementación es un contrato precompilado que está escrito en Go. Puedes consultar el repositorio de git de OffchainLabs aquí. Está destinado a ser una única tabla de direcciones universal en la que cualquiera puede registrarse y usarla.
Si quieres ver otra implementación escrita en Solidity, junto con su aplicación. Este repositorio de git de Solid Grinder contiene una versión modificada de UniswapV2, que adopta su propia tabla de direcciones.
Serialización de datos
La Serialización de datos funciona serializando y deserializando parámetros al tipo correcto con el tamaño de datos adecuado.
Por ejemplo, si elegimos reducir el calldata enviando el período de tiempo como argumentos con el tipo uint40 (5 bytes) en lugar de uint256, el calldata debe dividirse en el desplazamiento correcto y el resultado (después de eliminar los bytes cero) se puede usar correctamente en los siguientes pasos.
Echemos un vistazo a la implementación de Solid Grinder de nuevo aquí. Este contrato es un buen punto de partida:

Esta función decodificadora es específica de la aplicación para UniswapV2 y se genera con la CLI de Solid Grinder al observar la función original no optimizada. En este caso, es UniswapV2Router02. Básicamente, puedes experimentar y seguir los pasos detallados aquí en Quick Start.
Compromisos (Tradeoffs)
Los compromisos más claros de los trucos de optimización de gas en calldata mencionados anteriormente son la legibilidad y la complejidad. Por ejemplo,
Agregar lógicas de codificación y decodificación en el smart contract y eliminar explícitamente los parámetros de la función no solo confundirá a los usuarios que interactúen directamente con el contrato a través de Etherscan, sino que también dificultará el trabajo de los desarrolladores que quieran construir sobre tu smart contract modificado, reduciendo la componibilidad, que es la fortaleza única del mundo sin permisos.
Reflexiones finales
Como se mencionó anteriormente, la optimización de gas en calldata es un tema nuevo, pero se volverá cada vez más relevante a medida que la tecnología de capa 2 / rollup se vuelva más dominante. Además, todavía no existe un estándar o práctica clara. Este artículo solo proporciona y propone posibles enfoques y decisiones de diseño. Hay mucho más margen para reinventar este paradigma.
Referencias
- https://docs.arbitrum.io/arbos/l1-pricing#l1-fee-collection
- https://docs.arbitrum.io/stylus/reference/opcode-hostio-pricing#opcode-costs
- https://github.com/OffchainLabs/arbitrum-tutorials/tree/master/packages/address-table
- https://community.optimism.io/docs/developers/build/transaction-fees/
- https://scopelift.co/blog/calldata-optimizooooors
- https://github.com/clabby/op-kompressor
- https://github.com/Ratimon/solid-grinder
Publicado originalmente el 30 de enero de 2024