Tiempo estimado: 8 minutos de lectura
Estrategias clave para optimizar el consumo de gas en contratos Solidity, mejorando la eficiencia durante el despliegue y la ejecución. Se exploran patrones y consejos prácticos para equilibrar un menor consumo de gas con un código mantenible y claro.
¿Qué es la optimización de gas en Solidity?
La optimización de gas en Solidity consiste en programar los Smart Contracts siguiendo una serie de pautas que reduzcan el consumo de gas, de forma general tanto en el despliegue como en la ejecución.
El despliegue y la ejecución de un Smart Contract en la blockchain conlleva unos costos, denominados gas, que deben asumirse como contraprestación por el uso de la infraestructura.
La cantidad de gas consumida en el despliegue de un Smart Contract está directamente relacionada con el espacio de almacenamiento que ocupa su código, el número de variables de estado definidas y el coste de ejecución del constructor en la inicialización del Smart Contract. Se puede tener una visión más detallada en este artículo sobre los costes de creación de un Smart Contract.
Por otra parte el gas consumido al invocar los métodos de un Smart Contract en una transacción (escritura en la blockchain) se calcula a partir del coste asignado a los códigos de operación (opcodes) en que se traduce cada función ejecutada. En este enlace se puede acceder a la lista completa de códigos de operación de la EVM y su coste en gas.
Patrones de optimización de gas en Solidity
A continuación se enumeran algunos de los patrones más comunes para reducir el consumo de gas en los contratos programados en Solidity.
En ningún caso se puede considerar una lista completa, ya que en muchos casos la optimización se debe llevar a cabo a partir del análisis de la lógica ejecutada en cada función del código.
Por otra parte se han dejado al margen muchas optimizaciones más profundas como por ejemplo incluir secciones en ensamblador o usar desplazamiento de bits para multiplicaciones y divisiones por potencias de dos.
Se ha seguido el criterio de balancear adecuadamente la claridad y limpieza del código programado frente a las optimizaciones. Llevar al extremo las pautas para reducir el consumo de gas puede conducir a un código fuente muy ofuscado y difícil de mantenter.
Por otra parte, la verificación del cumplimiento de ciertas optimizaciones se puede automatizar usando determinadas herramientas, como por ejemplo Solhint. Se resalta explícitamente qué reglas estarían cubiertas con esa herramieta.
Lista de optimizaciones
- No inicializar una variable si su valor por defecto nos conviene. Aplicable tanto a variables de estado como locales. Por ejemplo se podría aplicar a la inicialización de un bucle for, evitando inicializar la variable «index»:
for (uint256 index; index < totalAssets; ++index) {
- «Cachear» las variables de estado para prevenir incurrir en sobrecostes por múltiples lecturas. En el segundo ejemplo la variable de estado «value» se lee una sola vez mientras que en el primero se lee dos veces.
uint256 constant MAX_VALUE = 1000;
contract Uncached {
uint256 public value;
function addOne() public {
require(value < MAX_VALUE);
value = value + 1;
}
}
contract Cached {
uint256 public value;
function addOne() public {
uint256 _value = value;
require(_value < MAX_VALUE);
value = _value + 1;
}
}
- (Solhint) Empaquetar los struct cuando sea posible. Consiste en definir secuencialmente los elementos del struct de manera que se aproveche el espacio de almacenamiento al máximo. En el ejemplo tanto «age» (8 bits) como «owner» (160 bits) pueden ser empaquetados en el mismo «slot» ya que ocupan menos de 256 bits.
struct packedStruct {
uint8 age;
address owner;
uint256 tokens;
}
- (Solhint) Mantener la longitud de los strings por debajo de 32 bytes para ocupar tan sólo un «slot» de memoria.
- Usar variables de estado de tipo constante o inmutable (asignadas en el despliegue) cuando no cambien de valor. De este modo son embebidas en el «bytecode» del contrato en lugar de ocupar espacio de tipo «storage», ahorrando gas en las lecturas.
- Usar «mappings» en lugar de «arrays» siempre que sea posible ya que suponen un cierto ahorro de gas en las lecturas. En general los arrays serán necesarios cuando haga falta iterar sobre la lista de datos. En el siguiente ejemplo se ilustra cómo reemplazar un array por un mapping:
contract CommonArray {
uint256[] list;
constructor() {
list.push() = 1;
list.push() = 2;
list.push() = 3;
}
function valueAt(uint256 index) external view returns(uint256) {
return list[index];
}
}
contract MappingReplacement {
mapping(uint256 => uint256) list;
constructor() {
list[0] = 1;
list[1] = 2;
list[2] = 3;
}
function valueAt(uint256 index) external view returns(uint256) {
return list[index];
}
}
- (Solhint) En funciones, usar parámetros de entrada de tipo «calldata» en lugar de «memory» cuando no se vaya a modificar el valor.
function certifyBatch(InputCertificate[] calldata inputs) public {
....
}
- Usar referencias a variables en el área de «storage» (punteros) en lugar de copias en memoria cuando suponga un beneficio. En el siguiente ejemplo se produce un ahorro de gas pues mediante el puntero tan sólo se accede al elemento deseado del struct en lugar de copiarlo completo en memoria:
contract StoragePointerVsMemory {
struct User {
string name;
string surname;
uint256 lastUpdated;
}
constructor() {
users[0] = User("John", "Doe", block.timestamp);
}
mapping(uint256 => User) public users;
function returnLastUpdatedNotOptimized(uint256 _id) public view returns (uint256) {
User memory _user = users[_id];
uint256 lastUpdated = block.timestamp - _user.lastUpdated;
return lastUpdated;
}
function returnLastUpdatedOptimized(uint256 _id) public view returns (uint256) {
User storage _user = users[_id];
uint256 lastUpdated = block.timestamp - _user.lastUpdated;
return lastUpdated;
}
}
- Desdoblar las condiciones en las sentencias «require» para evitar comprobaciones innecesarias. Queda ilustrado en el siguiente ejemplo:
// require(a > 0 && b > 0) is splitted
function splittedRequire(uint256 a, uint256 b) external pure returns (uint256) {
require(a > 0);
require(b > 0);
return a + b;
}
- Evitar la negación en las condiciones de las sentencias «if» para prevenir el sobrecoste del operador «!»:
function notOptimized() public {
if (!condition) {
function1();
}
else {
function2();
}
}
function optimized() public {
if (condition) {
function2();
}
else {
function1();
}
}
- (Solhint) Usar preincrementos/predecrementos (++i/- -i) en lugar de postincrementos/postdecrementos (i++/i- -), ahorrando operaciones de almacenamiento. Es especialmente útil en bucles.
for (uint256 index; index < totalAssets; ++index) {
- Usar bloques «unchecked» cuando no haya riesgos por «overflow» o «underflow» para evitar las comprobaciones extra introducidas por el compilador. Esto no es preciso en el autoincremento de los bucles «for» ya que desde la versión 0.8.22 del compilador se activa de forma automática el modo «unchecked» para esa sección de código.
- Siempre que sea posible, es preferible usar el tipo «uint256» sobre otros enteros más pequeños e incluso sobre el tipo «bool» puesto que al utilizar estos últimos son convertidos automáticamente a «uint256».
- Aprovechar la regla del «short-circuit» en la evaluación de expresiones condicionales compuestas. De este modo si se evalúa primero la condición más probable de fallar (para AND) o cumplirse (para OR), las siguientes no serán evaluadas en un alto número de ocasiones. Además es adecuado que esa condición ubicada en primer lugar sea la menos costosa desde el punto de vista del consumo de gas.
- Definir variables públicas sólo cuando sea necesario para evitar el sobrecoste en el despliegue por la generación automática del correspondiente «getter».
- En general conviene usar el optimizador de Solidity con valores altos para el parámetro «runs» de modo que se priorice menor consumo de gas en la ejecución del contrato frente a un despliegue algo más costoso.
- (Solhint) Usar «revert» y «require» con custom errors (disponible desde la versión 0.8.26 del compilador Solidity) en lugar de strings. En caso de usar strings, limitarlos a menos de 32 bytes.
error NotAllowed(uint256 amount, uint256 balance);
function transferWithRevert(uint256 amount) public {
if (amount > balance) {
revert NotAllowed(amount, balance);
}
....
}
function transferWithRequire(uint256 amount) public {
require(amount <= balance, NotAllowed(amount, balance));
....
}
NOTA: al menos hasta la versión 2.22.12 de Hardhat, la única forma de compilar contratos que usen Custom Errors en sentencias «require» es mediante pipeline «via-ir».
- Almacenar en memoria la longitud de los arrays en los bucles «for» para ahorar lecturas:
uint256 length = list.length;
for (uint256 i; i < length; ++i) {
// .....
}
- Minimizar las llamadas externas en los métodos del Smart Contract. Para ello, devolver el máximo de información en cada llamada a función.
- Usar variables de tamaño fijo frente a las de tamaño variable. Por ejemplo preferir «bytes32» frente a «string» o «bytes».
- Utilizar el patrón EIP-1167 para contratos que se desplieguen muchas veces y se invoquen poco.
- Reemplazar las librerías de OpenZeppelin por otras más eficientes desde el punto de vista del consumo de gas como podría ser Solady.
Conclusión: equilibrar un menor consumo de gas y un código mantenible
La optimización en el consumo de gas de un contrato tanto durante el despliegue como durante su invocación es un asunto de gran importancia para ajustar el coste de las transacciones asumido por los usuarios, contribuyendo además a aligerar la carga sobre la blockchain, facilitando su evolución en el tiempo.
Sin embargo conviene no perder de vista otro de los pilares de las buenas prácticas en el desarrollo web3: la claridad y legibilidad de los Smart Contracts para aumentar la transparencia y confianza en su uso.
Lo adecuado sería encontrar el punto de equilibrio entre un código limpio y mantenible que a su vez optimice el consumo de gas.
Por otra parte es recomendable asegurar que las modificaciones efectuadas para reducir el consumo de gas sean siempre comprobadas con las herramientas de «gas reporting» disponibles para los desarrolladores, al menos en aquellas optimizaciones más agresivas.
¿Qué te parece la lista de optimizaciones presentada? ¿Programas tus Smart Contracts con la mira puesta en reducir el consumo de gas? ¿Has aplicado otras mejoras distintas que también hayan sido efectivas? Déjame tus opiniones.
Por mi parte, puedo ayudarte a encontrar el punto de equilibrio en el desarrollo de tus Smart Contracts. Contáctame y hablamos.