ÍNDICE DE CONTENIDOS
El Libro de RareSkills sobre la Optimización de Gas
- Los trucos de optimización de gas no siempre funcionan
- Cuidado con la complejidad y la legibilidad
- Aquí no es posible un tratamiento exhaustivo de cada tema
- No discutimos trucos específicos de aplicaciones
- 1. Lo más importante: evitar escrituras en storage de cero a uno donde sea posible
- 2. Poner variables de storage en caché: escribir y leer variables de storage exactamente una vez
- 3. Empaquetar variables relacionadas
- 4. Empaquetar structs
- 5. Mantener los strings más pequeños de 32 bytes
- 6. Las variables que nunca se actualizan deben ser immutable o constant
- 7. Usar mappings en lugar de arrays para evitar comprobaciones de longitud
- 8. Usar unsafeAccess en arrays para evitar comprobaciones redundantes de longitud
- 9. Usar bitmaps en lugar de bools cuando se utiliza una cantidad significativa de booleanos
- 10. Usar SSTORE2 o SSTORE3 para almacenar una gran cantidad de datos
- 11. Usar punteros de storage en lugar de memory donde sea apropiado
- 12. Evitar que los saldos de tokens ERC20 lleguen a cero, mantener siempre una pequeña cantidad
- 13. Contar de n a cero en lugar de contar de cero a n
- 14. Los timestamps y los números de bloque no necesitan ser uint256
Ahorro de Gas en el Despliegue
- 1. Usar el nonce de la cuenta para predecir las direcciones de smart contracts interdependientes, evitando así variables de storage y funciones setter de direcciones
- 2. Hacer que los constructores sean payable
- 3. El tamaño de despliegue puede reducirse optimizando el hash de IPFS para tener más ceros (o usando la opción del compilador --no-cbor-metadata)
- 4. Usar selfdestruct en el constructor si el contrato es de un solo uso
- 5. Entender los compromisos al elegir entre funciones internal y modifiers
- 6. Usar clones o metaproxies al desplegar smart contracts muy similares que no se llaman con frecuencia
- 7. Las funciones de administrador pueden ser payable
- 8. Los errores personalizados (custom errors) son (generalmente) más pequeños que las declaraciones require
- 9. Usar fábricas create2 existentes en lugar de desplegar las tuyas propias
- 1. Usar transfer hooks para tokens en lugar de iniciar una transferencia desde el smart contract de destino
- 2. Usar fallback o receive en lugar de deposit() al transferir Ether
- 3. Usar transacciones de lista de acceso ERC2930 al hacer llamadas entre contratos para precalentar slots de storage
- 4. Poner en caché las llamadas a contratos externos donde tenga sentido (como poner en caché datos de retorno de un oráculo de chainlink)
- 5. Implementar multicall en contratos tipo router
- 6. Evitar las llamadas a contratos haciendo la arquitectura monolítica
- 1. Usar multidelegatecall para procesar transacciones por lotes
- 2. Usar firmas ECDSA en lugar de árboles de merkle para allowlists y airdrops
- 3. Usar ERC20Permit para procesar por lotes el paso de aprobación y transferencia en una sola transacción
- 4. Usar paso de mensajes de L2 para juegos u otras aplicaciones de alto rendimiento y bajo valor de transacción
- 5. Usar state-channels si es aplicable
- 6. Usar delegación de votos como medida de ahorro de gas
- 7. ERC1155 es un token no fungible más barato que ERC721
- 8. Usar un token ERC1155 o ERC6909 en lugar de varios tokens ERC20
- 9. El patrón de actualización UUPS es más eficiente en gas para los usuarios que el Transparent Upgradeable Proxy
- 10. Considerar el uso de alternativas a OpenZeppelin
- 1. Usar vanity addresses (¡de forma segura!)
- 2. Evitar los enteros con signo en calldata si es posible
- 3. Calldata es (generalmente) más barato que memory
- 4. Considerar empaquetar calldata, especialmente en una L2
- 1. Usar assembly para revertir con un mensaje de error
- 2. Llamar funciones a través de una interfaz incurre en costos de expansión de memory, así que usa assembly para reutilizar datos que ya están en memory
- 3. Las operaciones matemáticas comunes, como min y max, tienen alternativas eficientes en gas
- 4. Usar SUB o XOR en lugar de ISZERO(EQ()) para comprobar la desigualdad (más eficiente en ciertos escenarios)
- 5. Usar inline assembly para comprobar address(0)
- 6. selfbalance es más barato que address(this).balance (más eficiente en ciertos escenarios)
- 7. Usar assembly para realizar operaciones en datos de tamaño de 96 bytes o menos: hasheo y datos no indexados en eventos
- 8. Usar assembly para reutilizar el espacio de memory al hacer más de una llamada externa.
- 9. Usar assembly para reutilizar el espacio de memory al crear más de un contrato.
- 10. Comprobar si un número es par o impar verificando el último bit en lugar de usar un operador módulo
Relacionado con el Compilador de Solidity
- 1. Preferir las desigualdades estrictas sobre las desigualdades no estrictas, pero probar ambas alternativas
- 2. Dividir las declaraciones require que tienen expresiones booleanas
- 3. Dividir las declaraciones revert
- 4. Usar siempre Named Returns
- 5. Invertir las declaraciones if-else que tienen una negación
- 6. Usar ++i en lugar de i++ para incrementar
- 7. Usar matemáticas unchecked donde sea apropiado
- 8. Escribir bucles for óptimos en gas
- 9. Los bucles do-while son más baratos que los bucles for
- 10. Evitar el casting innecesario de variables, las variables más pequeñas que uint256 (incluyendo booleanos y address) son menos eficientes a menos que estén empaquetadas
- 11. Cortocircuitar booleanos
- 12. No hacer que las variables sean public a menos que sea necesario hacerlo
- 13. Preferir valores muy grandes para el optimizador
- 14. Las funciones muy utilizadas deben tener nombres óptimos
- 15. El bitshifting es más barato que multiplicar o dividir por una potencia de dos
- 16. A veces es más barato poner calldata en caché
- 17. Usar algoritmos branchless como reemplazo de condicionales y bucles
- 18. Las funciones internal que solo se usan una vez pueden integrarse en línea (inlined) para ahorrar gas
- 19. Comparar la igualdad de arrays y la igualdad de strings hasheándolos si son más largos de 32 bytes
- 20. Usar lookup tables al calcular potencias y logaritmos
- 21. Los contratos precompilados pueden ser útiles para algunas operaciones de multiplicación o de memory
- 22. n * n * n puede ser más barato que n ** 3
- 1. Usar gasprice() o msg.value para pasar información
- 2. Manipular variables de entorno como coinbase() o block.number si las pruebas lo permiten
- 3. Usar gasleft() para ramificar decisiones en puntos clave
- 4. Usar send() para mover ether, pero no comprobar el éxito
- 5. Hacer que todas las funciones sean payable
- 6. Salto de biblioteca externa
- 7. Añadir bytecode al final del contrato para crear una subrutina altamente optimizada
Optimización de Gas en Solidity
La optimización de gas en Ethereum consiste en reescribir código en Solidity para lograr la misma lógica de negocio consumiendo menos unidades de gas en la Ethereum Virtual Machine (EVM).
Con más de 11.000 palabras, sin incluir el código fuente, este artículo es el tratamiento más completo sobre optimización de gas disponible.
Para entender completamente los trucos de este tutorial, necesitarás comprender cómo funciona la EVM, lo cual puedes aprender tomando nuestro Gas Optimization Course, Yul Course, y practicando Huff Puzzles.
Sin embargo, si simplemente deseas saber a qué áreas del código apuntar para posibles optimizaciones de gas, este artículo te proporciona una gran cantidad de áreas donde buscar.
Autoría
Los investigadores de RareSkills Michael Amadi (LinkedIn, Twitter) y Jesse Raymond (LinkedIn, Twitter) contribuyeron significativamente a este trabajo.
Los trucos de optimización de gas no siempre funcionan
Algunos trucos de optimización de gas solo funcionan en un cierto contexto. Por ejemplo, intuitivamente, parecería que
if (!cond) {
// branch False
}
else {
// branch True
}
es menos eficiente que
if (cond) {
// branch True
}
else {
// branch False
}
porque se gastan opcodes adicionales invirtiendo la condición. Contrario a la intuición, hay muchos casos donde esta optimización en realidad incrementa el costo de la transacción. El compilador de solidity puede ser impredecible a veces. Por lo tanto, debes medir realmente el efecto de las alternativas antes de decidirte por un cierto algoritmo. Piensa que algunos de estos trucos sirven para crear conciencia sobre áreas donde el compilador puede ser sorprendente.
Algunos trucos que no son universales están marcados como tales en este documento. Los trucos de optimización de gas a veces dependen de lo que el compilador esté haciendo localmente. Generalmente deberías probar tanto la versión óptima del código como la versión no óptima para ver si realmente obtienes una mejora. Documentaremos algunos casos sorprendentes donde lo que debería conducir a una optimización en realidad lleva a un costo mayor.
En segundo lugar, parte de este comportamiento de optimización puede cambiar cuando se utiliza la opción --via-ir en el compilador de Solidity.
Cuidado con la complejidad y la legibilidad
Las optimizaciones de gas generalmente hacen que el código sea menos legible y más complejo. Un buen ingeniero debe hacer un compromiso subjetivo sobre qué optimizaciones valen la pena y cuáles no.
Aquí no es posible un tratamiento exhaustivo de cada tema
No podemos explicar cada optimización en detalle, y realmente no es necesario, ya que existen otros recursos en línea. Por ejemplo, dar un tratamiento completo, o incluso sustancial, a las L2 y a los state-channels estaría fuera del alcance, y hay otros recursos en línea para aprender esos temas en detalle.
El propósito de este artículo es ser la lista de trucos más completa que existe. Si un truco te resulta poco familiar, puede ser un punto de partida para que estudies por tu cuenta. Si el encabezado se parece a un truco que ya conoces, simplemente ojea esa sección.
No discutimos trucos específicos de aplicaciones
Existen formas eficientes en gas para determinar si un número es primo, por ejemplo, pero esto rara vez es necesario, por lo que dedicarle espacio reduciría el valor de este artículo. De manera similar, en nuestro tutorial de Tornado Cash, sugerimos formas en que la base de código podría hacerse más eficiente, pero incluir ese tratamiento aquí no beneficiaría a los lectores ya que es demasiado específico de la aplicación.
1. Lo más importante: evitar escrituras en storage de cero a uno donde sea posible
Inicializar una variable de storage es una de las operaciones más costosas que un contrato puede hacer.
Cuando una variable de storage pasa de cero a distinto de cero, el usuario debe pagar un total de 22.100 unidades de gas (20.000 de gas por una escritura de cero a distinto de cero y 2.100 por un acceso de cold storage).
Es por esto que el reentrancy guard de OpenZeppelin registra las funciones como activas o no con 1 y 2 en lugar de 0 y 1. Solo cuesta 5.000 de gas modificar una variable de storage de distinto de cero a distinto de cero.
2. Poner variables de storage en caché: escribir y leer variables de storage exactamente una vez
Verás frecuentemente el siguiente patrón en código eficiente de solidity. Leer desde una variable de storage cuesta al menos 100 de gas, ya que Solidity no almacena en caché la lectura de storage. Las escrituras son considerablemente más caras. Por lo tanto, debes almacenar manualmente en caché la variable para realizar exactamente una lectura de storage y exactamente una escritura de storage.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract Counter1 {
uint256 public number;
function increment() public {
require(number < 10);
number = number + 1;
}
}
contract Counter2 {
uint256 public number;
function increment() public {
uint256 _number = number;
require(_number < 10);
number = _number + 1;
}
}
La primera función lee counter dos veces, el segundo código lo lee una vez.
3. Empaquetar variables relacionadas
Empaquetar variables relacionadas en el mismo slot reduce los costos de gas minimizando operaciones costosas relacionadas con el storage.
El empaquetado manual es el más eficiente
Almacenamos y recuperamos dos valores uint80 en una sola variable (uint160) utilizando el desplazamiento de bits (bit shifting). Esto utilizará solo un slot de storage y es más barato al almacenar o leer los valores individuales en una sola transacción.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract GasSavingExample {
uint160 public packedVariables;
function packVariables(uint80 x, uint80 y) external {
packedVariables = uint160(x) << 80 | uint160(y);
}
function unpackVariables() external view returns (uint80, uint80) {
uint80 x = uint80(packedVariables >> 80);
uint80 y = uint80(packedVariables);
return (x, y);
}
}
El empaquetado de la EVM es ligeramente menos eficiente
Esto también utiliza un slot como el ejemplo anterior, pero puede ser un poco costoso al almacenar o leer valores en una sola transacción. Esto se debe a que la EVM hará el desplazamiento de bits (bit-shifting) por sí misma.
contract GasSavingExample2 {
uint80 public var1;
uint80 public var2;
function updateVars(uint80 x, uint80 y) external {
var1 = x;
var2 = y;
}
function loadVars() external view returns (uint80, uint80) {
return (var1, var2);
}
}
No empaquetar es lo menos eficiente
Esto no utiliza ninguna optimización, y es más costoso al almacenar o leer valores.
A diferencia de los otros ejemplos, esto utiliza dos slots de storage para almacenar las variables.
contract NonGasSavingExample {
uint256 public var1;
uint256 public var2;
function updateVars(uint256 x, uint256 y) external {
var1 = x;
var2 = y;
}
function loadVars() external view returns (uint256, uint256) {
return (var1, var2);
}
}
4. Empaquetar structs
Empaquetar elementos de un struct, al igual que empaquetar variables de estado relacionadas, puede ayudar a ahorrar gas.
(Es importante tener en cuenta que en Solidity, los miembros de un struct se almacenan secuencialmente en el storage del contrato, comenzando desde la posición del slot donde se inicializan).
Considera los siguientes ejemplos:
Struct sin empaquetar
El unpackedStruct tiene tres elementos que se almacenarán en tres slots separados. Sin embargo, si estos elementos estuvieran empaquetados, solo se usarían dos slots, lo que haría más barato leer y escribir en los elementos del struct.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract Unpacked_Struct {
struct unpackedStruct {
uint64 time; // Takes one slot - although it only uses 64 bits (8 bytes) out of 256 bits (32 bytes).
uint256 money; // This will take a new slot because it is a complete 256 bits (32 bytes) value and thus cannot be packed with the previous value.
address person; // An address occupies only 160 bits (20 bytes).
}
// Starts at slot 0
unpackedStruct details = unpackedStruct(53_000, 21_000, address(0xdeadbeef));
function unpack() external view returns (unpackedStruct memory) {
return details;
}
}
Struct empaquetado
Podemos hacer que el ejemplo anterior use menos gas empaquetando los elementos del struct de esta manera.
contract Packed_Struct {
struct packedStruct {
uint64 time; // In this case, both `time` (64 bits) and `person` (160 bits) are packed in the same slot since they can both fit into 256 bits (32 bytes)
address person; // Same slot as `time`. Together they occupy 224 bits (28 bytes) out of 256 bits (32 bytes).
uint256 money; // This will take a new slot because it is a complete 256 bits (32 bytes) value and thus cannot be packed with the previous value.
}
// Starts at slot 0
packedStruct details = packedStruct(53_000, address(0xdeadbeef), 21_000);
function unpack() external view returns (packedStruct memory) {
return details;
}
}
5. Mantener los strings más pequeños de 32 bytes
En Solidity, los strings son tipos de datos dinámicos de longitud variable, lo que significa que su longitud puede cambiar y crecer según sea necesario.
Si la longitud es de 32 bytes o más, el slot en el que están definidos almacena la longitud del string * 2 + 1, mientras que sus datos reales se almacenan en otro lugar (el hash keccak de ese slot).
Sin embargo, si un string tiene menos de 32 bytes, la length * 2 se almacena en el byte menos significativo de su slot de storage y los datos reales del string se almacenan comenzando desde el byte más significativo en el slot en el que está definido.
Ejemplo de string (menos de 32 bytes)
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract StringStorage1 {
// Uses only one slot
// slot 0: 0x(len * 2)00...hex of (len * 2)(hex"hello")
// Has smaller gas cost due to size.
string public exampleString = "hello";
function getString() public view returns (string memory) {
return exampleString;
}
}
Ejemplo de string (más de 32 bytes)
contract StringStorage2 {
// Length is more than 32 bytes.
// Slot 0: 0x00...(length*2+1).
// keccak256(0x00): stores hex representation of "hello"
// Has increased gas cost due to size.
string public exampleString = "This is a string that is slightly over 32 bytes!";
function getStringLongerThan32bytes() public view returns (string memory) {
return exampleString;
}
}
Podemos poner esto a prueba con el siguiente script de prueba de foundry:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import "forge-std/Test.sol";
import "../src/StringLessThan32Bytes.sol";
contract StringStorageTest is Test {
StringStorage1 public store1;
StringStorage2 public store2;
function setUp() public {
store1 = new StringStorage1();
store2 = new StringStorage2();
}
function testStringStorage1() public {
// test for string less than 32 bytes
store1.getString();
bytes32 data = vm.load(address(store1), 0); // slot 0
emit log_named_bytes32("Full string plus length", data); // the full string and its length*2 is stored at slot 0, because it is less than 32 bytes
}
function testStringStorage2() public {
// test for string longer than 32 bytes
store2.getStringLongerThan32bytes();
bytes32 length = vm.load(address(store2), 0); // slot 0 stores the length*2+1
emit log_named_bytes32("Length of string", length);
// uncomment to get original length as number
// emit log_named_uint("Real length of string (no. of bytes)", uint256(length) / 2);
// divide by 2 to get the original length
bytes32 data1 = vm.load(address(store2), keccak256(abi.encode(0))); // slot keccak256(0)
emit log_named_bytes32("First string chunk", data1);
bytes32 data2 = vm.load(address(store2), bytes32(uint256(keccak256(abi.encode(0))) + 1));
emit log_named_bytes32("Second string chunk", data2);
}
}
Este es el resultado después de ejecutar la prueba.
Si concatenamos el valor hexadecimal del string (más largo que 32 bytes) sin la longitud, lo convertimos de nuevo al string original (con Python).
Si la longitud de un string es menor a 32 bytes, también es eficiente almacenarlo en una variable bytes32 y usar assembly para utilizarlo cuando sea necesario.
Ejemplo:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.21;
contract EfficientString {
bytes32 shortString;
function getShortString() external view returns(string memory) {
string memory value;
assembly {
// get slot 0
let slot0Value := sload(shortString.slot)
// to get the byte that holds the length info, we mask it to rmove the string and divide it by 2 to get the length
let len := div(and(slot0Value, 0xff), 2)
// to get string, we mask the slot value to remove the length// we are sure that it can't take more than a byte because of the length check in the `storeShortString` function
let str := and(slot0Value, 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00)
// store length in memory
mstore(0x80, len)
// store string in memory
mstore(0xa0, str)
// make `value` reference 0x80 so that solidity does the returning for us
value := 0x80// update the free memory pointer
mstore(0x40, 0xc0)
}
return value;
}
function storeShortString(string calldata value) external {
assembly {
// require that the length is less than 32
if gt(value.length, 31) {
revert(0, 0)
}
// multiply the length, so we can store length*2 following solidity's convention
let length := mul(value.length, 2)
// get the string itself
let str := calldataload(value.offset)
// or the length and str to get what we need to store in storage
let toBeStored := or(str, length)
// store it in storage
sstore(shortString.slot, toBeStored)
}
}
}
El código anterior se puede optimizar aún más, pero se mantuvo de esta manera para facilitar su comprensión.
6. Las variables que nunca se actualizan deben ser immutable o constant
En Solidity, las variables que no están destinadas a ser actualizadas deben ser constant o immutable.
Esto se debe a que los valores constants e immutable están integrados directamente en el bytecode del contrato en el que están definidos y, por lo tanto, no usan storage.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract Constants {
uint256 constant MAX_UINT256 = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;
function get_max_value() external pure returns (uint256) {
return MAX_UINT256;
}
}
// This uses more gas than the above contract
contract NoConstants {
uint256 MAX_UINT256 = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;
function get_max_value() external view returns (uint256) {
return MAX_UINT256;
}
}
Esto ahorra mucho gas ya que no realizamos lecturas en storage, las cuales son costosas.
7. Usar mappings en lugar de arrays para evitar comprobaciones de longitud
Cuando almacenas una lista o un grupo de elementos que deseas organizar en un orden específico y recuperar con una clave/índice fijo, es una práctica común usar una estructura de datos de tipo array. Esto funciona bien, pero ¿sabías que se puede implementar un truco para ahorrar más de 2.000 de gas en cada lectura utilizando un mapping?
Observa el ejemplo a continuación:
/// get(0) gas cost: 4860
contract Array {
uint256[] a;
constructor() {
a.push() = 1;
a.push() = 2;
a.push() = 3;
}
function get(uint256 index) external view returns(uint256) {
return a[index];
}
}
/// get(0) gas cost: 2758
contract Mapping {
mapping(uint256 => uint256) a;
constructor() {
a[0] = 1;
a[1] = 2;
a[2] = 3;
}
function get(uint256 index) external view returns(uint256) {
return a[index];
}
}
Con solo usar un mapping, obtenemos un ahorro de gas de 2.102. ¿Por qué? Por debajo, cuando lees el valor de un índice de un array, solidity agrega un bytecode que comprueba que estás leyendo desde un índice válido (es decir, un índice estrictamente menor a la longitud del array), de lo contrario revierte con un error de tipo panic (para ser precisos, Panic(0x32)). Esto previene que se lea de ubicaciones de storage/memory no asignadas o, lo que es peor, asignadas.
Debido a la forma en que funcionan los mappings (simplemente un par clave => valor), no existe una comprobación como esa y podemos leer desde un slot de storage directamente. Es importante tener en cuenta que al usar mappings de esta manera, tu código debe asegurar que no estás leyendo un índice fuera de los límites (out of bound) de tu array canónico.
8. Usar unsafeAccess en arrays para evitar comprobaciones redundantes de longitud
Una alternativa a usar mappings para evitar las comprobaciones de longitud que solidity realiza al leer de los arrays (sin dejar de usar arrays), es usar la función unsafeAccess en la biblioteca Arrays.sol de OpenZeppelin. Esto permite a los desarrolladores acceder directamente a los valores de cualquier índice dado de un array mientras omiten la comprobación de desbordamiento de longitud. Sigue siendo importante usar esto solo si estás seguro de que los índices pasados a la función no pueden exceder la longitud del array proporcionado.
9. Usar bitmaps en lugar de bools cuando se utiliza una cantidad significativa de booleanos
Un patrón común, especialmente en airdrops, es marcar una address como “ya utilizada” cuando se reclama el airdrop o se mintea un NFT.
Sin embargo, dado que solo se necesita un bit para almacenar esta información, y cada slot tiene 256 bits, esto significa que se pueden almacenar 256 flags/booleanos con un solo slot de storage.
Puedes aprender más sobre esta técnica en estos recursos:
Video tutorial by a student at RareSkills
Bitmap presale tutorial
10. Usar SSTORE2 o SSTORE3 para almacenar una gran cantidad de datos
SSTORE
SSTORE es un opcode de la EVM que nos permite almacenar datos persistentes basándose en clave y valor. Como todo en la EVM, una clave y un valor son ambos valores de 32 bytes.
Los costos de escritura (SSTORE) y lectura (SLOAD) son muy caros en términos de gas gastado. Escribir 32 bytes cuesta 22.100 gas, lo que se traduce en aproximadamente 690 de gas por byte. Por otro lado, escribir el bytecode de un smart contract cuesta 200 de gas por byte.
SSTORE2
SSTORE2 es un concepto único en el sentido de que utiliza el bytecode de un contrato para escribir y almacenar datos. Para lograr esto, utilizamos la propiedad inherente de inmutabilidad del bytecode.
Algunas propiedades de SSTORE2:
- Solo podemos escribir una vez. Usando efectivamente
CREATEen lugar deSSTORE. - Para leer, en lugar de usar
SLOAD, ahora llamamos aEXTCODECOPYen la address desplegada donde el dato en particular se almacena como bytecode. - Escribir datos se vuelve significativamente más barato cuando se necesita almacenar más y más datos.
Ejemplo:
Escribir datos
Nuestro objetivo es almacenar un dato específico (en formato de bytes) como bytecode del contrato. Para lograr esto, necesitamos hacer 2 cosas:
- Copiar nuestros datos a memory primero, ya que la EVM luego toma estos datos de memory y los almacena como código en tiempo de ejecución. Puedes aprender más en nuestro artículo sobre contract creation code.
- Devolver y almacenar la address del contrato recién desplegado para su uso futuro.
- Agregamos el tamaño del código del contrato en lugar de los cuatro ceros (0000) entre el 61 y el 80 en el código de abajo
0x61000080600a3d393df300. Por lo tanto, si el tamaño del código es 65, se convertirá en0x61004180600a3d393df300(0x0041 = 65) - Este bytecode es responsable del paso 1 que mencionamos.
- Ahora devolvemos la address recién desplegada para el paso 2.
- Agregamos el tamaño del código del contrato en lugar de los cuatro ceros (0000) entre el 61 y el 80 en el código de abajo
Bytecode final del contrato = 00 + datos (se antepone 00 = STOP para asegurar que el bytecode no pueda ser ejecutado al llamar la address por error).
Leer datos
- Para obtener los datos relevantes, necesitas la address donde almacenaste los datos.
- Revertimos si el tamaño del código es = 0 por razones obvias.
- Ahora simplemente devolvemos el bytecode del contrato desde la posición inicial relevante, que es después de 1 byte (recuerda que el primer byte es
STOP OPCODE(0x00)).
Información adicional para los curiosos:
- También podemos usar una address predeterminada utilizando
CREATE2para calcular la address del puntero off chain o on chain sin depender de almacenar el puntero.
Ref: solady
SSTORE3
Para entender SSTORE3, primero recapitulemos una propiedad importante de SSTORE2.
- La address recién desplegada depende de los datos que pretendemos almacenar.
Escribir datos
SSTORE3 implementa un diseño de tal manera que la address recién desplegada es independiente de nuestros datos proporcionados. Los datos proporcionados primero se almacenan en storage utilizando SSTORE. Luego pasamos un INIT_CODE constante como datos en CREATE2, el cual internamente lee los datos proporcionados almacenados en storage para desplegarlo como código.
Esta elección de diseño nos permite calcular eficientemente la address del puntero de nuestros datos simplemente proporcionando el salt (que puede ser de menos de 20 bytes). Permitiéndonos así empaquetar nuestro puntero con otras variables, reduciendo así los costos de storage.
Leer datos
Trata de imaginar cómo podríamos estar leyendo los datos.
- La respuesta es que podemos calcular fácilmente la address desplegada simplemente proporcionando el salt.
- Luego, después de recibir la address del puntero, usar el mismo opcode
EXTCODECOPYpara obtener los datos requeridos.
Para resumir:
SSTORE2es útil en casos donde las operaciones de escritura son raras, y las operaciones grandes de lectura son frecuentes (y el puntero > 14 bytes)SSTORE3es mejor cuando escribes muy raramente, pero lees muy a menudo. (y el puntero < 14 bytes)
Créditos a Philogy por SSTORE3.
11. Usar punteros de storage en lugar de memory donde sea apropiado
En Solidity, los punteros de storage son variables que hacen referencia a una ubicación en el storage de un contrato. No son exactamente lo mismo que los punteros en lenguajes como C/C++.
Es útil saber cómo usar los punteros de storage de manera eficiente para evitar lecturas de storage innecesarias y realizar actualizaciones de storage eficientes en gas.
Aquí hay un ejemplo que muestra dónde los punteros de storage pueden ser útiles.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract StoragePointerUnOptimized {
struct User {
uint256 id;
string name;
uint256 lastSeen;
}
constructor() {
users[0] = User(0, "John Doe", block.timestamp);
}
mapping(uint256 => User) public users;
function returnLastSeenSecondsAgo(uint256 _id) public view returns (uint256) {
User memory _user = users[_id];
uint256 lastSeen = block.timestamp - _user.lastSeen;
return lastSeen;
}
}
Arriba, tenemos una función que devuelve la última vez visto (last seen) de un usuario en un índice dado. Obtiene el valor lastSeen y lo resta del block.timestamp actual. Luego copiamos todo el struct en memory y obtenemos el lastSeen, que usamos para calcular el last seen en segundos. Este método funciona bien pero no es tan eficiente, esto se debe a que estamos copiando todo el struct desde storage hacia memory, incluyendo variables que no necesitamos. Si al menos hubiera una forma de leer solo desde el slot de storage de lastSeen (sin usar assembly). Ahí es donde entran los punteros de storage.
// This results in approximately 5,000 gas savings compared to the previous version.
contract StoragePointerOptimized {
struct User {
uint256 id;
string name;
uint256 lastSeen;
}
constructor() {
users[0] = User(0, "John Doe", block.timestamp);
}
mapping(uint256 => User) public users;
function returnLastSeenSecondsAgoOptimized(uint256 _id) public view returns (uint256) {
User storage _user = users[_id];
uint256 lastSeen = block.timestamp - _user.lastSeen;
return lastSeen;
}
}
“La implementación anterior resulta en un ahorro aproximado de 5.000 de gas comparado con la primera versión”. ¿Por qué es así? El único cambio aquí fue modificar memory a storage y se nos había dicho que cualquier cosa de storage es costosa y debe evitarse.
Aquí almacenamos el puntero de storage para users[_id] en una variable de tamaño fijo en el stack (el puntero de un struct es básicamente el slot de storage de inicio del struct, en este caso, este será el slot de storage de user[_id].id). Dado que los punteros de storage son “perezosos” (lazy) (lo que significa que solo actúan (leen o escriben) cuando se llaman o se hace referencia a ellos). A continuación, solo accedemos a la clave lastSeen del struct. De esta forma hacemos una sola carga desde storage y luego la almacenamos en el stack, en lugar de 3 o posiblemente más cargas desde storage y un almacenamiento en memory antes de tomar un pequeño fragmento desde memory al stack.
Nota: Al utilizar punteros de storage, es importante tener cuidado de no hacer referencia a punteros colgantes (dangling pointers). (Aquí hay un video tutorial sobre dangling pointers realizado por uno de los instructores de RareSkills).
12. Evitar que los saldos de tokens ERC20 lleguen a cero, mantener siempre una pequeña cantidad
Esto está relacionado con la sección de evitar escrituras de cero a uno vista anteriormente, pero vale la pena mencionarlo por separado porque su implementación es un tanto sutil.
Si una address vacía (y recarga) frecuentemente el saldo de su cuenta, esto conducirá a una gran cantidad de escrituras de cero a uno.
13. Contar de n a cero en lugar de contar de cero a n
Cuando se establece una variable de storage a cero, se otorga un reembolso, por lo que el gas neto gastado en contar será menor si el estado final de la variable de storage es cero.
14. Los timestamps y los números de bloque no necesitan ser uint256
Un timestamp de tamaño uint48 funcionará durante millones de años en el futuro. Un número de bloque se incrementa una vez cada 12 segundos. Esto debería darte una idea del tamaño de números que es sensato utilizar.
Ahorro de Gas en el Despliegue
1. Usar el nonce de la cuenta para predecir las direcciones de smart contracts interdependientes, evitando así variables de storage y funciones setter de direcciones
Al usar el despliegue tradicional de contratos, la address de un smart contract puede calcularse de manera determinista basándose en la address del desplegador (deployer) y su nonce. La biblioteca LibRLP de Solady puede ayudarnos a hacer exactamente eso.
Toma el siguiente escenario de ejemplo;
StorageContract solo permite que el Writer establezca la variable de storage x, lo que significa que necesita conocer la address del Writer. Pero para que el Writer escriba en el StorageContract, también necesita conocer la address del StorageContract.
La siguiente implementación es un enfoque ingenuo para este problema. Lo maneja teniendo una función setter que establece una variable de storage después del despliegue. Pero las variables de storage son costosas y preferiríamos evitarlas.
contract StorageContract {
address immutable public writer;
uint256 public x;
constructor(address _writer) {
writer = _writer;
}
function setX(uint256 x_) external {
require(msg.sender == address(writer), "only writer can set");
x = x_;
}
}
contract Writer {
StorageContract public storageContract;
// cost: 49291
function set(uint256 x_) external {
storageContract.setX(x_);
}
function setStorageContract(address _storageContract) external {
storageContract = StorageContract(_storageContract);
}
}
Esto cuesta más tanto en el despliegue como en el tiempo de ejecución. Esto implica desplegar el Writer, luego desplegar el StorageContract con la address del Writer desplegado configurada como el writer. Luego, establecer la variable StorageContract del Writer con el StorageContract recién creado. Esto implica muchos pasos y puede ser costoso ya que almacenamos StorageContract en storage. Llamar a Writer.setX() cuesta 49k de gas.
Una manera más eficiente de hacer esto sería calcular de antemano la address a la que se desplegarán el StorageContract y el Writer y establecerlas en ambos constructores.
Aquí hay un ejemplo de cómo se vería esto:
import {LibRLP} from "https://github.com/vectorized/solady/blob/main/src/utils/LibRLP.sol";
contract StorageContract {
address immutable public writer;
uint256 public x;
constructor(address _writer) {
writer = _writer;
}
// cost: 47158
function setX(uint256 x_) external {
require(msg.sender == address(writer), "only writer can set");
x = x_;
}
}
contract Writer {
StorageContract immutable public storageContract;
constructor(StorageContract _storageContract) {
storageContract = _storageContract;
}
function set(uint256 x_) external {
storageContract.setX(x_);
}
}
// one time deployer.
contract BurnerDeployer {
using LibRLP for address;
function deploy() public returns(StorageContract storageContract, address writer) {
StorageContract storageContractComputed = StorageContract(address(this).computeAddress(2)); // contracts nonce start at 1 and only increment when it creates a contract
writer = address(new Writer(storageContractComputed)); // first creation happens here using nonce = 1
storageContract = new StorageContract(writer); // second create happens here using nonce = 2
require(storageContract == storageContractComputed, "false compute of create1 address"); // sanity check
}
}
Aquí, llamar a Writer.setX() cuesta 47k de gas. Ahorramos más de 2k de gas precalculando la address a la que se desplegaría el StorageContract antes de desplegarlo, para poder usarla al desplegar el Writer, eliminando la necesidad de una función setter.
No es necesario usar un contrato separado para emplear esta técnica, puedes hacerlo dentro del script de despliegue en su lugar.
Proporcionamos un video tutorial de predicción de address realizado por Philogy si deseas explorar esto a fondo.
2. Hacer que los constructores sean payable
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.20;
contract A {}
contract B {
constructor() payable {}
}
Hacer el constructor payable ahorró 200 de gas en el despliegue. Esto se debe a que las funciones no-payable tienen un require(msg.value == 0) implícito insertado en ellas. Adicionalmente, menos bytecode al momento de desplegar significa menos costo de gas debido a un calldata más pequeño.
Existen buenas razones para hacer que las funciones regulares sean no-payable, pero generalmente un contrato es desplegado por una address privilegiada que se puede asumir razonablemente que no enviará ether. Esto podría no aplicar si usuarios sin experiencia están desplegando el contrato.
3. El tamaño de despliegue puede reducirse optimizando el hash de IPFS para tener más ceros (o usando la opción del compilador --no-cbor-metadata)
Ya hemos explicado esto en nuestro tutorial sobre metadatos de smart contracts, pero para recapitular, el compilador de Solidity añade 51 bytes de metadatos al código real del smart contract. Dado que cada byte de despliegue cuesta 200 de gas, eliminarlos puede quitar más de 10.000 de costo de gas del despliegue.
Sin embargo, esto no siempre es ideal, ya que puede afectar la verificación del smart contract. En su lugar, los desarrolladores pueden minar comentarios de código para hacer que el hash de IPFS que se anexa tenga más ceros.
4. Usar selfdestruct en el constructor si el contrato es de un solo uso
A veces, los contratos se usan para desplegar varios contratos en una sola transacción, lo cual hace necesario hacerlo en el constructor.
Si el único uso del contrato es el código en el constructor, entonces autodestruirse (selfdestructing) al final de la operación ahorrará gas.
Aunque selfdestruct está programado para ser eliminado en un próximo hardfork, seguirá siendo compatible en el constructor según el EIP 6780.
5. Entender los compromisos al elegir entre funciones internal y modifiers
Los modifiers inyectan su bytecode de implementación donde se utilizan, mientras que las funciones internal saltan a la ubicación en el código de tiempo de ejecución (runtime code) donde se encuentra su implementación. Esto trae ciertos compromisos para ambas opciones.
- Usar modifiers más de una vez significa repetitividad y un aumento en el tamaño del runtime code, pero reduce el costo de gas por la ausencia de saltar al offset de ejecución de la función internal y saltar de regreso para continuar. Esto significa que si el costo de gas en tiempo de ejecución es lo que más te importa, entonces los modifiers deberían ser tu elección, pero si el costo de gas de despliegue y/o reducir el tamaño del código de creación es lo más importante para ti, entonces usar funciones internal será lo mejor.
- Sin embargo, los modifiers tienen el inconveniente de que solo pueden ejecutarse al inicio o al final de una función. Esto significa que ejecutarlos a mitad de una función no sería directamente posible, al menos no sin usar funciones internal que arruinen el propósito original. Esto afecta su flexibilidad. Las funciones internal, sin embargo, pueden llamarse en cualquier punto de una función.
Ejemplo mostrando la diferencia en costo de gas usando modifiers y una función internal
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
/** deployment gas cost: 195435
gas per call:
restrictedAction1: 28367
restrictedAction2: 28377
restrictedAction3: 28411
*/
contract Modifier {
address owner;
uint256 val;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
function restrictedAction1() external onlyOwner {
val = 1;
}
function restrictedAction2() external onlyOwner {
val = 2;
}
function restrictedAction3() external onlyOwner {
val = 3;
}
}
/** deployment gas cost: 159309
gas per call:
restrictedAction1: 28391
restrictedAction2: 28401
restrictedAction3: 28435
*/
contract InternalFunction {
address owner;
uint256 val;
constructor() {
owner = msg.sender;
}
function onlyOwner() internal view {
require(msg.sender == owner);
}
function restrictedAction1() external {
onlyOwner();
val = 1;
}
function restrictedAction2() external {
onlyOwner();
val = 2;
}
function restrictedAction3() external {
onlyOwner();
val = 3;
}
}
| Operation | Deployment | restrictedAction1 | restrictedAction2 | restrictedAction3 |
|---|---|---|---|---|
| Modifiers | 195435 | 28367 | 28377 | 28411 |
| Internal Functions | 159309 | 28391 | 28401 | 28435 |
Desde la tabla anterior, podemos ver que el contrato que usa modifiers cuesta más de 35k de gas adicional que el contrato usando funciones internal al desplegarlo, debido a la repetición de la funcionalidad onlyOwner en 3 funciones.
Durante el tiempo de ejecución, podemos ver que cada función usando modifiers cuesta exactamente 24 de gas menos que las funciones usando funciones internal.
6. Usar clones o metaproxies al desplegar smart contracts muy similares que no se llaman con frecuencia
Al desplegar múltiples smart contracts similares, los costos de gas pueden ser altos. Para reducir estos costos, puedes usar minimal clones o metaproxies que almacenan la address del contrato de implementación en su bytecode e interactúan con él como un proxy.
Sin embargo, hay un compromiso entre el costo en tiempo de ejecución y el costo de despliegue de los clones. Es más caro interactuar con los clones que con los contratos normales debido al delegatecall que usan, por lo que solo deberían usarse cuando no necesites interactuar con ellos con frecuencia. Por ejemplo, el contrato de Gnosis Safe usa clones para reducir los costos de despliegue.
Aprende más sobre cómo usar clones y metaproxies para reducir los costos de gas de desplegar smart contracts en nuestros artículos de blog:
7. Las funciones de administrador pueden ser payable
Podemos hacer que las funciones específicas para el administrador sean payable para ahorrar gas, porque el compilador no estará comprobando el callvalue de la función.
Esto también hará que el contrato sea más pequeño y barato de desplegar ya que habrá menos opcodes en el código de creación y de ejecución.
8. Los errores personalizados (custom errors) son (generalmente) más pequeños que las declaraciones require
Los custom errors son más baratos que las declaraciones require con strings debido a cómo se manejan los custom errors. Solidity almacena solo los primeros 4 bytes del hash de la firma del error y devuelve solo eso. Esto significa que durante el revert, solo 4 bytes necesitan almacenarse en memory. En el caso de los mensajes string en declaraciones require, Solidity tiene que almacenar (en memory) y revertir con al menos 64 bytes.
Aquí hay un ejemplo a continuación.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract CustomError {
error InvalidAmount();
function withdraw(uint256 _amount) external pure {
if (_amount > 10 ether) revert InvalidAmount();
}
}
// This uses more gas than the above contract
contract NoCustomError {
function withdraw(uint256 _amount) external pure {
require(_amount <= 10 ether, "Error: Pass in a valid amount");
}
}
9. Usar fábricas create2 existentes en lugar de desplegar las tuyas propias
El título es autoexplicativo. Si necesitas una address determinista, generalmente puedes reutilizar una pre-desplegada.
Llamadas entre contratos
1. Usar transfer hooks para tokens en lugar de iniciar una transferencia desde el smart contract de destino
Digamos que tienes el contrato A que acepta el token B (un NFT o un token ERC1363). El flujo de trabajo ingenuo es el siguiente:
msg.senderaprueba alcontrato Apara aceptar eltoken Bmsg.senderllama alcontrato Apara transferir tokens desdemsg.senderaA- El
Contrato Aluego llama altoken Bpara hacer la transferencia - El
Token Bhace la transferencia, y llama aonTokenReceived()en elcontrato A - El
Contrato Adevuelve un valor desdeonTokenReceived()altoken B - El
Token Bdevuelve la ejecución alcontrato A
Esto es muy ineficiente. Es mejor que el msg.sender llame al contrato B para hacer una transferencia que a su vez llama al transfer hook tokenReceived en el contrato A.
Ten en cuenta que:
- Todos los tokens ERC1155 incluyen un transfer hook
safeTransferysafeMinten ERC721 tienen un transfer hook- ERC1363 tiene
transferAndCall - ERC777 tiene un transfer hook pero ha sido desaprobado (deprecated). Usa ERC1363 o ERC1155 en su lugar si necesitas tokens fungibles
Si necesitas pasar argumentos al contrato A, simplemente usa el campo data y analízalo (parse) en el contrato A.
2. Usar fallback o receive en lugar de deposit() al transferir Ether
Similar a lo anterior, puedes “simplemente transferir” ether a un contrato y hacer que responda a la transferencia en lugar de usar una función payable. Esto por supuesto, depende del resto de la arquitectura del contrato.
Ejemplo de Deposit en AAVE
contract AddLiquidity{
receive() external payable {
IWETH(weth).deposit{msg.value}();
AAVE.deposit(weth, msg.value, msg.sender, REFERRAL_CODE)
}
}
La función fallback es capaz de recibir datos bytes que pueden ser procesados con abi.decode. Esto sirve como una alternativa a proporcionar argumentos a una función deposit.
3. Usar transacciones de lista de acceso ERC2930 al hacer llamadas entre contratos para precalentar slots de storage
Las transacciones de access list te permiten prepagar los costos de gas para algunas operaciones de storage y de llamada, con un descuento de 200 de gas. Esto puede ahorrar gas en accesos posteriores al estado o al storage, los cuales se pagan como un acceso cálido (warm access).
Si tu transacción realizará una llamada entre contratos, casi seguro que deberías estar usando transacciones de access list.
Al llamar a clones o proxies, los cuales siempre implican una llamada entre contratos mediante delegatecall, deberías hacer que la transacción sea una transacción de access list.
Tenemos un post de blog dedicado a esto, visita https://www.rareskills.io/post/eip-2930-optional-access-list-ethereum para aprender más.
4. Poner en caché las llamadas a contratos externos donde tenga sentido (como poner en caché datos de retorno de un oráculo de chainlink)
Generalmente se recomienda poner los datos en caché para evitar la duplicación en memory cuando deseas usar los mismos datos > 1 vez durante un solo proceso de ejecución.
Un ejemplo obvio es si necesitas realizar múltiples operaciones, por ejemplo, usando el precio de ETH obtenido de Chainlink. Almacenas el precio en memory, en lugar de hacer nuevamente la costosa llamada externa.
5. Implementar multicall en contratos tipo router
Esta es una característica común, como en el Router de Uniswap y el Bulker de Compound. Si esperas que tus usuarios realicen una secuencia de llamadas, haz que un contrato las agrupe por lotes usando multicall.
6. Evitar las llamadas a contratos haciendo la arquitectura monolítica
Las llamadas a contratos son costosas, y la mejor manera de ahorrar gas en ellas es simplemente no usarlas. Existe un compromiso natural con esto, pero tener varios contratos que se hablan entre sí a veces puede aumentar el gas y la complejidad en lugar de gestionarlos.
Patrones de Diseño
1. Usar multidelegatecall para procesar transacciones por lotes
El multidelegatecall ayuda al msg.sender a llamar múltiples funciones dentro de un contrato mientras conserva variables de entorno como msg.sender y msg.value.
Nota: Ten cuidado ya que dado que msg.value es persistente, puede llevar a problemas que el desarrollador necesita abordar al heredar multidelegatecall en su contrato.
Un ejemplo de Multidelegatecall es la implementación de Uniswap a continuación:
function multicall(bytes[] calldata data) public payable override returns (bytes[] memory results) {
results = new bytes[](data.length);
for (uint256 i = 0; i < data.length; i++) {
(bool success, bytes memory result) = address(this).delegatecall(data[i]);
if (!success) {
// Next 5 lines from https://ethereum.stackexchange.com/a/83577
if (result.length < 68) revert();
assembly {
result := add(result, 0x04)
}
revert(abi.decode(result, (string)));
}
results[i] = result;
}
}
2. Usar firmas ECDSA en lugar de árboles de merkle para allowlists y airdrops
Los árboles de merkle usan una cantidad considerable de calldata y aumentan en costo con el tamaño de la prueba de merkle. Generalmente, usar firmas digitales es más barato en cuanto a gas comparado con las pruebas de merkle.
3. Usar ERC20Permit para procesar por lotes el paso de aprobación y transferencia en una sola transacción
ERC20 Permit tiene una función adicional que acepta firmas digitales de un poseedor de tokens para incrementar la aprobación hacia otra address. De esta manera, el receptor de la aprobación puede enviar la transacción del permit y la transferencia en una sola transacción. El usuario que concede el permit no tiene que pagar nada de gas, y el receptor del permit puede agrupar la transacción del permit y de transferFrom en una sola transacción.
4. Usar paso de mensajes de L2 para juegos u otras aplicaciones de alto rendimiento y bajo valor de transacción
Etherorcs fue uno de los pioneros tempranos de este patrón, por lo que puedes revisar su Github (enlazado arriba) para obtener inspiración. La idea es que los activos en Ethereum pueden ser transferidos como “bridge” (vía paso de mensajes) a otra cadena como Polygon, Optimism, o Arbitrum y el juego puede llevarse a cabo ahí donde las transacciones son baratas.
5. Usar canales de estado (state-channels) si es aplicable
Los state-channels son probablemente la solución de escalabilidad más antigua de Ethereum, pero aún siguen siendo utilizables. A diferencia de las L2, son específicos para una aplicación. En lugar de que los usuarios confirmen sus transacciones en una cadena, comprometen activos en un smart contract y luego comparten firmas vinculantes entre sí como transiciones de estado. Cuando la operación termina, entonces comprometen el resultado final en la cadena.
Si uno de los participantes es deshonesto, entonces un participante honesto puede usar la firma de la contraparte para forzar al smart contract a liberar sus activos.
6. Usar delegación de votos como medida de ahorro de gas
Nuestro tutorial sobre ERC20 Votes describe este patrón con más detalle. En lugar de que cada propietario de token vote, solo votan los delegados, lo que en suma reduce el número de votos.
7. ERC1155 es un token no fungible más barato que ERC721
La función balanceOf de ERC721 rara vez se usa en la práctica pero añade una sobrecarga en el storage cada vez que ocurre un mint y una transferencia. ERC1155 rastrea el saldo por id, y también usa el mismo saldo para rastrear la propiedad del id. Si la oferta máxima para cada id es de uno, entonces el token se vuelve no fungible para cada id.
8. Usar un token ERC1155 o ERC6909 en lugar de varios tokens ERC20
Esta fue la intención original del token ERC1155. Cada token individual se comporta como un ERC20, pero solo se necesita desplegar un contrato.
El inconveniente de este enfoque es que los tokens no serán compatibles con la mayoría de las primitivas de intercambio (swapping) de DeFi.
ERC1155 usa callbacks en todos los métodos de transferencia. Si esto no es deseado, se puede usar ERC6909 en su lugar.
9. El patrón de actualización UUPS es más eficiente en gas para los usuarios que el Transparent Upgradeable Proxy
El patrón transparent upgradeable proxy requiere comparar msg.sender con el admin cada vez que ocurre una transacción. UUPS solo hace esto para la función de upgrade.
10. Considerar el uso de alternativas a OpenZeppelin
OpenZeppelin es una excelente y popular biblioteca de smart contracts, pero hay otras alternativas que vale la pena considerar. Estas alternativas ofrecen mejor eficiencia de gas y han sido probadas y recomendadas por desarrolladores.
Dos ejemplos de tales alternativas son Solmate y Solady.
Solmate es una biblioteca que proporciona varias implementaciones eficientes en gas de patrones comunes de smart contracts. Solady es otra biblioteca eficiente en gas que pone un fuerte énfasis en el uso de assembly.
Optimizaciones de Calldata
1. Usar vanity addresses (¡de forma segura!)
Es más barato usar vanity addresses con ceros iniciales, esto ahorra costo de gas en el calldata.
Un buen ejemplo es el contrato Seaport de OpenSea con esta address:
0x00000000000000ADc04C56Bf30aC9d3c0aAF14dC.
Esto no ahorrará gas al llamar directamente a la address. Sin embargo, si la address de ese contrato se usa como argumento para una función, esa llamada de función costará menos gas por tener más ceros en el calldata.
Esto también aplica al pasar EOAs con muchos ceros como argumento de una función – ahorra gas por la misma razón.
Solo ten en cuenta que ha habido hacks al generar vanity addresses para wallets con claves privadas insuficientemente aleatorias. Esto no es una preocupación para las vanity addresses de smart contracts creadas buscando un salt para create2, porque los smart contracts no tienen claves privadas.
2. Evitar los enteros con signo en calldata si es posible
Debido a que solidity utiliza el complemento a dos (two’s complement) para representar enteros con signo, el calldata para números negativos pequeños será en gran parte distinto de cero. Por ejemplo, -1 es 0xff…ff en forma de complemento a dos y, por lo tanto, más costoso.
3. Calldata es (generalmente) más barato que memory
Cargar inputs de función o datos directamente desde calldata es más barato en comparación con cargarlos desde memory. Esto se debe a que acceder a los datos desde calldata implica menos operaciones y costos de gas. Por lo tanto, se aconseja usar memory solo cuando los datos necesitan ser modificados en la función (el calldata no se puede modificar).
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract CalldataContract {
function getDataFromCalldata(bytes calldata data) public pure returns (bytes memory) {
return data;
}
}
contract MemoryContract {
function getDataFromMemory(bytes memory data) public pure returns (bytes memory) {
return data;
}
}
4. Considerar empaquetar calldata, especialmente en una L2
Solidity empaqueta automáticamente las variables de storage, pero el abi encoding para variables que estarían empaquetadas en storage no están empaquetadas en calldata.
Esta es una optimización bastante extrema que lleva a una mayor complejidad en el código, pero es algo a considerar si una función recibe mucho calldata.
El ABI encoding no es eficiente para todas las representaciones de datos, algunas representaciones de datos pueden codificarse de manera más eficiente de forma específica a la aplicación.
Más sobre esta técnica se discute en nuestro artículo sobre Optimización de Calldata en L2.
Actualización A partir de la actualización Dencun, la mayoría de las L2 ya no publican calldata en la L1, sino que usan blobs, por lo que aunque reducir el tamaño del calldata seguirá ahorrando costos, los ahorros ya no son tan significativos.
Trucos de Assembly
No debes asumir que escribir assembly automáticamente resultará en un código más eficiente. Hemos listado áreas donde escribir assembly generalmente funciona mejor, pero siempre debes probar la versión sin assembly.
1. Usar assembly para revertir con un mensaje de error
Al revertir en código de solidity, es una práctica común usar un require o una declaración revert para revertir la ejecución con un mensaje de error. Esto en la mayoría de los casos se puede optimizar aún más utilizando assembly para revertir con el mensaje de error.
Aquí hay un ejemplo:
/// calling restrictedAction(2) with a non-owner address: 24042
contract SolidityRevert {
address owner;
uint256 specialNumber = 1;
constructor() {
owner = msg.sender;
}
function restrictedAction(uint256 num) external {
require(owner == msg.sender, "caller is not owner");
specialNumber = num;
}
}
/// calling restrictedAction(2) with a non-owner address: 23734
contract AssemblyRevert {
address owner;
uint256 specialNumber = 1;
constructor() {
owner = msg.sender;
}
function restrictedAction(uint256 num) external {
assembly {
if sub(caller(), sload(owner.slot)) {
mstore(0x00, 0x20) // store offset to where length of revert message is stored
mstore(0x20, 0x13) // store length (19)
mstore(0x40, 0x63616c6c6572206973206e6f74206f776e657200000000000000000000000000) // store hex representation of message
revert(0x00, 0x60) // revert with data
}
}
specialNumber = num;
}
}
En el ejemplo anterior podemos ver que obtenemos un ahorro de gas de más de 300 revirtiendo con el mismo mensaje de error con assembly frente a hacerlo en solidity. Estos ahorros de gas provienen de los costos de expansión de memory y las comprobaciones adicionales de tipo que el compilador de solidity hace de forma subyacente.
2. Llamar funciones a través de una interfaz incurre en costos de expansión de memory, así que usa assembly para reutilizar datos que ya están en memory
Al llamar a una función en un contrato B desde otro contrato A, lo más conveniente es usar la interfaz, crear una instancia de B con una address y llamar a la función que deseamos llamar. Esto funciona muy bien, pero debido a cómo solidity compila nuestro código, almacena los datos a enviar al contrato B en una nueva ubicación de memory, expandiendo así memory, a veces innecesariamente.
Con inline assembly, podemos optimizar mejor nuestro código y ahorrar algo de gas usando ubicaciones de memory utilizadas previamente que no necesitamos de nuevo o (si el calldata que espera el contrato B es menor de 64 bytes) en el scratch space para almacenar nuestro calldata.
Aquí hay un ejemplo comparando los dos:
/// 30570
contract Sol {
function set(address addr, uint256 num) external {
Callme(addr).setNum(num);
}
}
/// 30350
contract Assembly {
function set(address addr, uint256 num) external {
assembly {
mstore(0x00, hex"cd16ecbf")
mstore(0x04, num)
if iszero(extcodesize(addr)) {
revert(0x00, 0x00) // revert if address has no code deployed to it
}
let success := call(gas(), addr, 0x00, 0x00, 0x24, 0x00, 0x00)
if iszero(success) {
revert(0x00, 0x00)
}
}
}
}
contract Callme {
uint256 num = 1;
function setNum(uint256 a) external {
num = a;
}
}
Podemos ver que llamar a set(uint256) en Assembly cuesta 220 de gas menos de lo que costaría si usáramos solidity.
Ten en cuenta que al usar inline assembly para hacer llamadas externas, es importante comprobar si la address a la que estamos llamando tiene código desplegado en ella utilizando extcodesize(addr) y revertir si esto devuelve 0. Esto es importante porque llamar a una address que no tiene código desplegado siempre devuelve true, lo que puede ser devastador para la lógica de nuestro contrato en la mayoría de los escenarios.
3. Las operaciones matemáticas comunes, como min y max, tienen alternativas eficientes en gas
Sin optimizar
function max(uint256 x, uint256 y) public pure returns (uint256 z) {
z = x > y ? x : y;
}
Optimizado
function max(uint256 x, uint256 y) public pure returns (uint256 z) {
/// @solidity memory-safe-assembly
assembly {
z := xor(x, mul(xor(x, y), gt(y, x)))
}
}
El código anterior está tomado de la sección de matemáticas de la biblioteca Solady, allí se pueden encontrar más operaciones matemáticas. Vale la pena explorar la biblioteca para ver qué operaciones eficientes en gas están disponibles para ti.
La razón por la que el ejemplo anterior es más eficiente en gas es porque el operador ternario (y en general, el código con condicionales) contiene saltos condicionales en los opcodes, que son más costosos.
Aquí está nuestro video tutorial sobre max sin ramificaciones (branchless) que explica el código anterior.
4. Usar SUB o XOR en lugar de ISZERO(EQ()) para comprobar la desigualdad (más eficiente en ciertos escenarios)
Al usar inline assembly para comparar la igualdad de dos valores (por ejemplo, si el owner es el mismo que caller()), a veces es más eficiente hacer
if sub(caller, sload(owner.slot)) {
revert(0x00, 0x00)
}
en lugar de hacer esto
if eq(caller, sload(owner.slot)) {
revert(0x00, 0x00)
}
xor puede lograr lo mismo, pero ten en cuenta que xor considerará igual también a un valor con todos sus bits invertidos, así que asegúrate de que esto no pueda convertirse en un vector de ataque.
Este truco dependerá de la versión del compilador utilizada y del contexto del código.
5. Usar inline assembly para comprobar address(0)
Escribir contratos en inline assembly generalmente se considera optimizado para gas. Podemos manipular memory directamente y usar menos opcodes en lugar de dejárselo al compilador de Solidity.
Los mecanismos de autenticación son un ejemplo donde el uso de inline assembly es bueno, como implementar la comprobación de la dirección cero (address zero).
Aquí hay un ejemplo a continuación:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract NormalAddressZeroCheck {
function check(address _caller) public pure returns (bool) {
require(_caller != address(0x00), "Zero address");
return true;
}
}
contract AddressZeroCheckAssembly {
// Saves about 90 gas
function checkOptimized(address _caller) public pure returns (bool) {
assembly {
if iszero(_caller) {
mstore(0x00, 0x20)
mstore(0x20, 0x0c)
mstore(0x40, 0x5a65726f20416464726573730000000000000000000000000000000000000000) // load hex of "Zero Address" to memory
revert(0x00, 0x60)
}
}
return true;
}
}
6. selfbalance es más barato que address(this).balance (más eficiente en ciertos escenarios)
El código de solidity address(this).balance a veces puede hacerse de forma más eficiente con la función selfbalance() de Yul, pero ten en cuenta que a veces el compilador es lo suficientemente inteligente como para usar este truco internamente, por lo que prueba de ambas maneras.
7. Usar assembly para realizar operaciones en datos de tamaño de 96 bytes o menos: hasheo y datos no indexados en eventos
Solidity siempre escribe en memory expandiéndola, lo cual a veces no es eficiente. Podemos optimizar las operaciones de memory en datos de 96 bytes o menos utilizando inline-assembly.
Solidity reserva sus primeros 64 bytes de memory (mem[0x00:0x40]) como un scratch space que los devs pueden usar para realizar cualquier operación con la garantía de que no será sobrescrito o leído de manera inesperada. Los siguientes 32 bytes de memory (mem[0x40:0x60]) es donde solidity almacena, lee y actualiza el free memory pointer. Así es como solidity hace un seguimiento del siguiente offset de memory para escribir nuevos datos. Los siguientes 32 bytes de memory (mem[0x60:0x80]) se llama el zero slot. Es a donde apuntan los datos de memory dinámica no inicializados (bytes memory, string memory, T[] memory (donde T es cualquier tipo válido)). Ya que estos valores están sin inicializar, solidity requiere que el slot al que apuntan (0x60) permanezca en 0x00.
Nota: Los structs almacenados en memory, incluso cuando son dinámicos (es decir, tienen un valor dinámico dentro de ellos), cuando no están inicializados no apuntan al zero slot.
Nota: Los datos de memory dinámica no inicializados aún apuntan al zero slot incluso si están anidados dentro de un struct.
Si podemos utilizar el scratch space para realizar operaciones en memory que el compilador normalmente expandiría memory para realizar si lo hiciera por sí mismo, entonces podemos optimizar nuestro código. Así que tenemos 64 bytes de memory más barata con los que trabajar ahora. El espacio del free memory pointer también se puede usar siempre y cuando lo actualicemos antes de salir del bloque de assembly. Podemos almacenarlo en el stack temporalmente para esto.
Veamos algunos ejemplos.
- Usar assembly para registrar hasta 96 bytes de datos no indexados
contract ExpensiveLogger {
event BlockData(uint256 blockTimestamp, uint256 blockNumber, uint256 blockGasLimit);
// cost: 26145
function returnBlockData() external {
emit BlockData(block.timestamp, block.number, block.gaslimit);
}
}
contract CheapLogger {
event BlockData(uint256 blockTimestamp, uint256 blockNumber, uint256 blockGasLimit);
// cost: 22790
function returnBlockData() external {
assembly {
mstore(0x00, timestamp())
mstore(0x20, number())
mstore(0x40, gaslimit())
log1(0x00,
0x60,
0x9ae98f1999f57fc58c1850d34a78f15d31bee81788521909bea49d7f53ed270b // event hash of BlockData
)
}
}
}
El ejemplo anterior muestra cómo podemos ahorrar casi 2.000 de gas usando memory para almacenar los datos que deseamos emitir en el evento BlockData.
No hay necesidad de actualizar nuestro free memory pointer aquí porque la ejecución termina justo después de que emitimos nuestro evento y nunca volvemos al código de solidity.
Tomemos otro ejemplo donde necesitaríamos actualizar el free memory pointer
- Usar assembly para hashear hasta 96 bytes de datos
contract ExpensiveHasher {
bytes32 public hash;
struct Values {
uint256 a;
uint256 b;
uint256 c;
}
Values values;
// cost: 113155function setOnchainHash(Values calldata _values) external {
hash = keccak256(abi.encode(_values));
values = _values;
}
}
contract CheapHasher {
bytes32 public hash;
struct Values {
uint256 a;
uint256 b;
uint256 c;
}
Values values;
// cost: 112107
function setOnchainHash(Values calldata _values) external {
assembly {
// cache the free memory pointer because we are about to override it
let fmp := mload(0x40)
// use 0x00 to 0x60
calldatacopy(0x00, 0x04, 0x60)
sstore(hash.slot, keccak256(0x00, 0x60))
// restore the cache value of free memory pointer
mstore(0x40, fmp)
}
values = _values;
}
}
En el ejemplo anterior, de manera similar al primero, usamos assembly para almacenar valores en los primeros 96 bytes de memory lo que nos ahorra más de 1.000 de gas. También nota que en esta instancia, debido a que volvemos al código de solidity, cacheamos y actualizamos nuestro free memory pointer al inicio y al final de nuestro bloque de assembly. Esto es para asegurar que las asunciones del compilador de solidity sobre lo que está almacenado en memory permanezcan compatibles.
8. Usar assembly para reutilizar el espacio de memory al hacer más de una llamada externa.
Una operación que hace que el compilador de solidity expanda memory es hacer llamadas externas. Al hacer llamadas externas, el compilador tiene que codificar la firma de la función que desea llamar en el contrato externo junto con sus argumentos en memory. Como sabemos, solidity no limpia ni reutiliza memory, por lo que tendrá que almacenar estos datos en el siguiente free memory pointer, lo cual expande memory aún más.
Con inline assembly, podemos usar ya sea el scratch space y el offset del free memory pointer para almacenar estos datos (como se vio arriba) si los argumentos de la función no ocupan más de 96 bytes en memory. Mejor aún, si estamos haciendo más de una llamada externa podemos reutilizar el mismo espacio en memory de las primeras llamadas para almacenar los nuevos argumentos en memory sin expandir memory innecesariamente. En este escenario, solidity expandiría memory tanto como la longitud de los datos devueltos lo requiera. Esto se debe a que los datos devueltos se almacenan en memory (en la mayoría de los casos). Si los datos devueltos son de menos de 96 bytes, podemos usar el scratch space para almacenarlos y prevenir expandir memory.
Ve el ejemplo a continuación;
contract Called {
function add(uint256 a, uint256 b) external pure returns(uint256) {
return a + b;
}
}
contract Solidity {
// cost: 7262
function call(address calledAddress) external pure returns(uint256) {
Called called = Called(calledAddress);
uint256 res1 = called.add(1, 2);
uint256 res2 = called.add(3, 4);
uint256 res = res1 + res2;
return res;
}
}
contract Assembly {
// cost: 5281
function call(address calledAddress) external view returns(uint256) {
assembly {
// check that calledAddress has code deployed to it
if iszero(extcodesize(calledAddress)) {
revert(0x00, 0x00)
}
// first call
mstore(0x00, hex"771602f7")
mstore(0x04, 0x01)
mstore(0x24, 0x02)
let success := staticcall(gas(), calledAddress, 0x00, 0x44, 0x60, 0x20)
if iszero(success) {
revert(0x00, 0x00)
}
let res1 := mload(0x60)
// second call
mstore(0x04, 0x03)
mstore(0x24, 0x4)
success := staticcall(gas(), calledAddress, 0x00, 0x44, 0x60, 0x20)
if iszero(success) {
revert(0x00, 0x00)
}
let res2 := mload(0x60)
// add results
let res := add(res1, res2)
// return data
mstore(0x60, res)
return(0x60, 0x20)
}
}
}
Ahorramos aproximadamente 2.000 de gas usando el scratch space para almacenar el selector de función y sus argumentos y también reutilizando el mismo espacio en memory para la segunda llamada mientras almacenamos los datos devueltos en el zero slot, sin expandir así memory.
Si los argumentos de la función externa que deseas llamar superan los 64 bytes y si estás haciendo una sola llamada externa, no ahorrarías un gas significativo al escribirlo en assembly. Sin embargo, si haces más de una llamada. Aún puedes ahorrar gas reutilizando el mismo slot de memory para las 2 llamadas usando inline assembly.
Nota: Recuerda siempre actualizar el free memory pointer si el offset al que apunta ya está siendo utilizado, para evitar que solidity sobrescriba los datos almacenados allí o use el valor almacenado allí de una forma inesperada.
También nota que debes evitar sobrescribir el zero slot (offset 0x60 de memory) si tienes valores dinámicos de memory indefinidos dentro de esa pila de llamadas (call stack). Una alternativa es definir explícitamente valores dinámicos de memory o, si se usan, devolver el slot a 0x00 antes de salir del bloque de assembly.
9. Usar assembly para reutilizar el espacio de memory al crear más de un contrato.
Solidity trata la creación de contratos de forma similar a las llamadas externas que devuelven 32 bytes (es decir, devuelve la address del contrato creado o address(0) si la creación del contrato falló).
A partir de la sección sobre ahorrar gas con llamadas externas, podemos ver inmediatamente que una forma en la que podemos optimizar esto es almacenar la address devuelta en el scratch space y evitar expandir memory.
Ve un ejemplo similar a continuación;
contract Solidity {
// cost: 261032
function call() external returns (Called, Called) {
Called called1 = new Called();
Called called2 = new Called();
return (called1, called2);
}
}
contract Assembly {
// cost: 260210
function call() external returns(Called, Called) {
bytes memory creationCode = type(Called).creationCode;
assembly {
let called1 := create(0x00, add(0x20, creationCode), mload(creationCode))
let called2 := create(0x00, add(0x20, creationCode), mload(creationCode))
// revert if either called1 or called2 returned address(0)
if iszero(and(called1, called2)) {
revert(0x00, 0x00)
}
mstore(0x00, called1)
mstore(0x20, called2)
return(0x00, 0x40)
}
}
}
contract Called {
function add(uint256 a, uint256 b) external pure returns(uint256) {
return a + b;
}
}
Ahorramos cerca de 1.000 de gas usando inline assembly.
Nota: En el escenario en el que los dos contratos a desplegar no son los mismos, el código de creación del segundo contrato tendría que ser almacenado con mstore manualmente usando inline assembly y no asignado a una variable en solidity para evitar la expansión de memory.
10. Comprobar si un número es par o impar verificando el último bit en lugar de usar un operador módulo
La manera convencional de comprobar si un número es par o impar es hacer x % 2 == 0 donde x es el número en cuestión. En su lugar, puedes comprobar si x & uint256(1) == 0, asumiendo que x es un uint256. Un AND a nivel de bits (bitwise and) es más barato que el opcode de módulo. En binario, el bit de más a la derecha representa “1”, mientras que todos los bits a la izquierda son múltiplos de 2, los cuales son pares. Añadir “1” a un número par hace que sea impar.
Relacionado con el Compilador de Solidity
Se sabe que los siguientes trucos mejoran la eficiencia del gas en el compilador de Solidity. Sin embargo, se espera que el compilador de Solidity mejore con el tiempo haciendo que estos trucos sean menos útiles o incluso contraproducentes.
No deberías usar a ciegas los trucos enumerados aquí, sino evaluar (benchmark) ambas alternativas.
Algunos de estos trucos ya están incorporados por el compilador cuando se usa la bandera (flag) del compilador --via-ir, y puede que incluso hagan que el código sea menos eficiente cuando se usa esa bandera.
Haz un Benchmark. Siempre haz un benchmark.
1. Preferir las desigualdades estrictas sobre las desigualdades no estrictas, pero probar ambas alternativas
Generalmente se recomienda usar desigualdades estrictas (<, >) en lugar de desigualdades no estrictas (<=, >=). Esto se debe a que el compilador a veces cambiará un > b por !(a < b) para lograr la desigualdad no estricta. La EVM no tiene un opcode para comprobar “menor o igual a” o “mayor o igual a”.
Sin embargo, deberías probar ambas comparaciones, porque no siempre el uso de la desigualdad estricta ahorrará gas. Esto depende mucho del contexto de los opcodes circundantes.
2. Dividir las declaraciones require que tienen expresiones booleanas
Cuando dividimos las declaraciones require, estamos esencialmente diciendo que cada declaración debe ser verdadera para que la función continúe ejecutándose.
Si la primera declaración evalúa a falso, la función revertirá de inmediato y las siguientes declaraciones require no serán examinadas. Esto ahorrará el costo de gas en lugar de evaluar la siguiente declaración require.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract Require {
function dontSplitRequireStatement(uint256 x, uint256 y) external pure returns (uint256) {
require(x > 0 && y > 0); // both conditon would be evaluated, before reverting or notreturn x * y;
}
}
contract RequireTwo {
function splitRequireStatement(uint256 x, uint256 y) external pure returns (uint256) {
require(x > 0); // if x <= 0, the call reverts and "y > 0" is not checked.
require(y > 0);
return x * y;
}
}
3. Dividir las declaraciones revert
De manera similar a dividir declaraciones require, usualmente ahorrarás algo de gas al no tener un operador booleano en la declaración if.
contract CustomErrorBoolLessEfficient {
error BadValue();
function requireGood(uint256 x) external pure {
if (x < 10 || x > 20) {
revert BadValue();
}
}
}
contract CustomErrorBoolEfficient {
error TooLow();
error TooHigh();
function requireGood(uint256 x) external pure {
if (x < 10) {
revert TooLow();
}
if (x > 20) {
revert TooHigh();
}
}
}
4. Usar siempre Named Returns
El compilador de solidity genera un código más eficiente cuando la variable se declara en la declaración de retorno. Parecen haber muy pocas excepciones a esto en la práctica, así que si ves un retorno anónimo, deberías probarlo con un retorno nombrado en su lugar para determinar qué caso es más eficiente.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract NamedReturn {
function myFunc1(uint256 x, uint256 y) external pure returns (uint256) {
require(x > 0);
require(y > 0);
return x * y;
}
}
contract NamedReturn2 {
function myFunc2(uint256 x, uint256 y) external pure returns (uint256 z) {
require(x > 0);
require(y > 0);
z = x * y;
}
}
5. Invertir las declaraciones if-else que tienen una negación
Este es el mismo ejemplo que dimos al principio del artículo. En el fragmento de código a continuación, la segunda función evita una negación innecesaria. En teoría, el ! adicional aumenta el costo computacional. Pero como notamos al inicio del artículo, deberías realizar un benchmark de ambos métodos porque a veces el compilador puede optimizar esto.
function cond() public {
if (!condition) {
action1();
}
else {
action2();
}
}
function cond() public {
if (condition) {
action2();
}
else {
action1();
}
}
6. Usar ++i en lugar de i++ para incrementar
La razón detrás de esto radica en cómo ++i y i++ son evaluados por el compilador. i++ devuelve i (su valor antiguo) antes de incrementar i a un nuevo valor. Esto significa que se almacenan 2 valores en el stack para su uso, ya sea que desees usarlos o no. ++i, por otro lado, evalúa la operación ++ en i (es decir, incrementa i) y luego devuelve i (su valor incrementado), lo que significa que solo se necesita almacenar un elemento en el stack.
7. Usar matemáticas unchecked donde sea apropiado
Solidity usa matemáticas comprobadas (checked) por defecto (es decir, revierte si el resultado de una operación matemática desborda el tipo de la variable de resultado), pero hay algunas situaciones donde el desbordamiento es imposible que ocurra.
- bucles for que tienen límites superiores naturales
- matemáticas donde el input a la función ya está sanitizado dentro de rangos razonables
- variables que empiezan en un número bajo y luego cada transacción suma uno o un número pequeño a ellas (como un contador)
Siempre que veas aritmética en el código, pregúntate si hay una guarda natural contra desbordamientos (overflow o underflow) en el contexto (ten en cuenta el tipo de la variable que guarda el número también). Si es así, añade un bloque unchecked.
8. Escribir bucles for óptimos en gas
Nota: A partir de Solidity 0.8.22, este truco es realizado automáticamente por el compilador y no es necesario hacerlo explícitamente.
Así es como se ve un bucle for óptimo en gas, si combinas los dos trucos anteriores:
for (uint256 i; i < limit; ) {
// inside the loop
unchecked {
++i;
}
}
Las dos diferencias aquí con un bucle for convencional son que i++ se convierte en ++i (como se notó anteriormente), y es unchecked porque la variable límite asegura que no se desbordará.
9. Los bucles do-while son más baratos que los bucles for
Si quieres llevar la optimización al límite a expensas de crear código un poco menos convencional, los bucles do-while de Solidity son más eficientes en gas que los bucles for, incluso si añades una condición if para comprobar el caso en que el bucle no se ejecute en absoluto.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
// times == 10 in both tests
contract Loop1 {
function loop(uint256 times) public pure {
for (uint256 i; i < times;) {
unchecked {
++i;
}
}
}
}
contract Loop2 {
function loop(uint256 times) public pure {
if (times == 0) {
return;
}
uint256 i;
do {
unchecked {
++i;
}
} while (i < times);
}
}
10. Evitar el casting innecesario de variables, las variables más pequeñas que uint256 (incluyendo booleanos y address) son menos eficientes a menos que estén empaquetadas
Es mejor usar uint256 para enteros, excepto cuando sean necesarios enteros más pequeños. Esto se debe a que la EVM convierte automáticamente los enteros más pequeños a uint256 cuando se usan. Este proceso de conversión añade costo de gas extra, por lo que es más eficiente usar uint256 desde el principio.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract Unnecessary_Typecasting {
uint8 public num;
function incrementNum() public {
num += 1;
}
}
// Uses less gas
contract NoTypecasting {
uint256 public num;
function incrementNumCheap() public {
num += 1;
}
}
11. Cortocircuitar booleanos
En Solidity, cuando evalúas una expresión booleana (por ejemplo, los operadores || (O lógico) o && (Y lógico)), en el caso de || la segunda expresión solo se evaluará si la primera evalúa a falso y en el caso de && la segunda expresión solo se evaluará si la primera evalúa a verdadero. Esto se llama cortocircuitar.
Por ejemplo, la expresión require(msg.sender == owner || msg.sender == manager) pasará si la primera expresión msg.sender == owner evalúa a verdadero. La segunda expresión msg.sender == manager no se evaluará en absoluto. Sin embargo, si la primera expresión msg.sender == owner evalúa a falso, la segunda expresión msg.sender == manager será evaluada para determinar si la expresión en general es verdadera o falsa. Aquí, al comprobar primero la condición que es más probable que pase, podemos evitar comprobar la segunda condición ahorrando así gas en la mayoría de las llamadas exitosas.
Esto es similar para la expresión require(msg.sender == owner && msg.sender == manager). Si la primera expresión msg.sender == owner evalúa a falso, la segunda expresión msg.sender == manager no será evaluada porque la expresión general no puede ser verdadera. Para que la declaración general sea verdadera, ambas partes de la expresión deben evaluar a verdadero. Aquí, al comprobar primero la condición que es más probable que falle, podemos evitar comprobar la segunda condición ahorrando así gas en la mayoría de los reverts de las llamadas.
Cortocircuitar es útil y se recomienda colocar primero la expresión menos costosa, ya que la más costosa podría ser omitida. Si la segunda expresión es más importante que la primera, podría valer la pena invertir su orden para que la más barata se evalúe primero.
12. No hacer que las variables sean public a menos que sea necesario hacerlo
Una variable de storage public tiene una función public implícita con el mismo nombre. Una función public aumenta el tamaño de la jump table y añade bytecode para leer la variable en cuestión. Eso hace que el contrato sea más grande. Recuerda, las variables private no son privadas, no es difícil extraer el valor de la variable usando web3.js. Esto es especialmente cierto para las variables constant, que están pensadas para ser leídas por humanos en lugar de smart contracts.
13. Preferir valores muy grandes para el optimizador
El optimizador de Solidity se centra en optimizar dos aspectos principales:
- El costo de despliegue de un smart contract.
- El costo de ejecución de las funciones dentro del smart contract.
Existe un compromiso involucrado al seleccionar el parámetro runs para el optimizador. Valores más pequeños para runs priorizan minimizar el costo de despliegue, resultando en un código de creación más pequeño pero potencialmente en un código de tiempo de ejecución (runtime code) no optimizado. Si bien esto reduce los costos de gas durante el despliegue, puede que no sea tan eficiente durante la ejecución.
Por el contrario, valores más grandes del parámetro runs priorizan el costo de ejecución. Esto conduce a un código de creación más grande pero a un runtime code optimizado que es más barato de ejecutar. Aunque esto puede no afectar significativamente los costos de gas de despliegue, puede reducir significativamente los costos de gas durante la ejecución.
Considerando este compromiso, si tu contrato se usará frecuentemente es recomendable usar un valor más grande para el optimizador. Ya que esto ahorrará costos de gas a largo plazo.
14. Las funciones muy utilizadas deben tener nombres óptimos
La EVM usa una jump table para las llamadas a funciones, y los selectores de funciones con menor orden hexadecimal se ordenan primero que los selectores con mayor orden hexadecimal. En otras palabras, si dos selectores de funciones, por ejemplo, 0x000071c3 y 0xa0712d68, están presentes en el mismo contrato, la función con el selector 0x000071c3 será comprobada antes que la que tiene 0xa0712d68 durante la ejecución del contrato.
Por lo tanto, si una función se usa frecuentemente, es esencial que tenga un nombre óptimo. Esta optimización aumenta sus posibilidades de ser ordenada primero, ahorrando así costos de gas de más comprobaciones (aunque si hay más de cuatro funciones en el contrato, la EVM hace una búsqueda binaria para la jump table en lugar de una búsqueda lineal).
Esto también reduce el costo del calldata (si la función tiene ceros a la izquierda, ya que los bytes cero cuestan 4 de gas, y los bytes que no son cero cuestan 16 de gas).
Aquí hay una buena demostración a continuación.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract FunctionWithLeadingZeros {
uint256 public totalSupply;
// selector = 0xa0712d68
function mint(uint256 amount) public {
totalSupply += amount;
}
// selector = 0x000071c3 (this cheaper than the above function)
function mint_184E17(uint256 amount) public {
totalSupply += amount;
}
}
Además, tenemos una herramienta útil llamada Solidity Zero Finder que fue construida con Rust y puede asistir a los desarrolladores a lograr esto. Está disponible en este repositorio de GitHub.
15. El bitshifting es más barato que multiplicar o dividir por una potencia de dos
En Solidity, a menudo es más eficiente en gas multiplicar o dividir números que son potencias de dos desplazando sus bits (bitshifting), en lugar de usar los operadores de multiplicación o división.
Por ejemplo, las dos expresiones siguientes son equivalentes
10 * 2
10 << 1 # shift 10 left by 1
y esto también es equivalente
8 / 4
8 >> 2 # shift 8 right by 2
Los opcodes de las operaciones de desplazamiento de bits en la EVM, tales como shr (desplazamiento a la derecha) y shl (desplazamiento a la izquierda), cuestan 3 de gas, mientras que las operaciones de multiplicación y división (mul y div) cuestan 5 de gas cada una.
La mayoría del ahorro de gas también proviene del hecho de que solidity no comprueba por desbordamientos (overflow/underflow) ni división por cero para las operaciones shr y shl. Es importante tener esto en mente al usar estos operadores para que no ocurran bugs de overflow y underflow.
16. A veces es más barato poner calldata en caché
Aunque la instrucción calldataload es un opcode barato, el compilador de solidity a veces generará código más barato si pones calldataload en caché. Este no siempre será el caso, así que debes probar ambas posibilidades.
contract LoopSum {
function sumArr(uint256[] calldata arr) public pure returns (uint256 sum) {
uint256 len = arr.length;
for (uint256 i = 0; i < len; ) {
sum += arr[i];
unchecked {
++i;
}
}
}
}
17. Usar algoritmos branchless como reemplazo de condicionales y bucles
El código de max de una sección anterior es un ejemplo de un algoritmo branchless (sin ramificaciones), es decir, elimina el opcode JUMP, que es más costoso que los opcodes aritméticos en general.
Los bucles for tienen saltos integrados en ellos, por lo que quizás quieras considerar el desenrollado de bucles (loop unrolling) para ahorrar gas.
Los bucles no tienen que ser desenrollados del todo. Por ejemplo, puedes ejecutar un bucle dos elementos a la vez y reducir el número de saltos a la mitad.
Esta es una optimización muy extrema, pero debes ser consciente de que los saltos condicionales y los bucles introducen un opcode ligeramente más costoso.
18. Las funciones internal que solo se usan una vez pueden integrarse en línea (inlined) para ahorrar gas
Está bien tener funciones internal, sin embargo estas introducen etiquetas de salto (jump labels) adicionales al bytecode.
Por lo tanto, en un caso donde solo sea usada por una función, es mejor integrar la lógica de la función internal dentro de la función en la que está siendo usada. Esto ahorrará algo de gas al evitar saltos durante la ejecución de la función.
19. Comparar la igualdad de arrays y la igualdad de strings hasheándolos si son más largos de 32 bytes
Este es un truco que raramente usarás, pero iterar (looping) sobre los arrays o strings es mucho más costoso que hashearlos y comparar los hashes.
20. Usar lookup tables al calcular potencias y logaritmos
Si necesitas obtener logaritmos o potencias donde la base o la potencia es una fracción, puede ser preferible precalcular una tabla (lookup table) si ya sea la base o la potencia es fija. Considera la Bancor Formula y la matemática de Uniswap V3 Tick Math como ejemplos.
21. Los contratos precompilados pueden ser útiles para algunas operaciones de multiplicación o de memory.
Los contratos precompilados de Ethereum proporcionan operaciones principalmente para la criptografía, pero si necesitas multiplicar números grandes sobre un módulo o copiar un bloque significativo de memory, considera usar los contratos precompilados. Ten en cuenta que esto podría hacer que tu aplicación sea incompatible con algunas L2s.
22. n * n * n puede ser más barato que n ** 3
Dos opcodes MUL cuestan 10 de gas en total, pero el opcode EXP cuesta 10 de gas + 50 * (tamaño del exponente en bytes).
Técnicas peligrosas
Si estás participando en un concurso de optimización de gas, entonces estos patrones de diseño inusuales pueden ayudar, pero utilizarlos en producción se desaconseja enormemente, o como mínimo debería hacerse con extrema precaución.
1. Usar gasprice() o msg.value para pasar información
Pasar parámetros a una función añadirá, como mínimo absoluto, 128 de gas, porque cada byte cero en el calldata cuesta 4 de gas. Sin embargo, puedes establecer el gasprice o el msg.value gratis para pasar números de esta manera. Esto por supuesto no funcionará en producción porque msg.value cuesta Ethereum real y si tu precio de gas es muy bajo, la transacción no se completará, o desperdiciará criptomonedas.
2. Manipular variables de entorno como coinbase() o block.number si las pruebas lo permiten
Por supuesto esto no funcionará en producción, pero puede servir como un canal lateral para modificar el comportamiento de un smart contract.
3. Usar gasleft() para ramificar decisiones en puntos clave
El gas se consume a medida que avanza la ejecución, por lo que si quieres hacer algo como terminar un bucle después de cierto punto o cambiar el comportamiento en una parte posterior de la ejecución, puedes usar la funcionalidad de gasprice() para ramificar la toma de decisiones. gasleft() se decrementa “gratis”, así que esto ahorra gas.
4. Usar send() para mover ether, pero no comprobar el éxito
La diferencia entre send y transfer es que transfer revierte si la transferencia falla, pero send devuelve false. Sin embargo, puedes simplemente ignorar el valor devuelto de send, y eso resultará en menos opcodes. Ignorar los valores de retorno es una muy mala práctica, y es una lástima que el compilador no te impida hacer eso. En sistemas en producción, no deberías usar send() en absoluto debido al límite de gas.
5. Hacer que todas las funciones sean payable
Esta es una optimización muy controvertida porque permite un cambio de estado inesperado en una transacción, y no ahorra tanto gas. Pero en el contexto de un concurso de gas, hacer todas las funciones payable evitará los opcodes extra que comprueban si msg.value es distinto de cero.
Como se notó antes, hacer que el constructor o las funciones de administrador sean payable es una forma legítima de ahorrar gas, ya que presumiblemente el desplegador (deployer) y el administrador saben lo que hacen y pueden hacer cosas más destructivas que enviar ether.
6. Salto de biblioteca externa
Solidity tradicionalmente utiliza 4 bytes y una jump table para determinar qué función utilizar. Sin embargo, uno puede (¡de manera muy insegura!) simplemente proporcionar el destino del salto como un argumento en el calldata, reduciendo el “selector de función” a solo un byte y evitando completamente la jump table. Puedes ver más información en este tweet.
7. Añadir bytecode al final del contrato para crear una subrutina altamente optimizada
Algunos algoritmos computacionalmente intensivos, como las funciones hash, se escriben mejor en bytecode puro en lugar de en Solidity, o incluso Yul. Por ejemplo, Tornado Cash escribe la función hash MiMC como un smart contract separado, escrito directamente en bytecode puro. Evita el costo adicional de 2.600 o 100 de gas (acceso en frío o en caliente) de otro smart contract añadiendo ese bytecode al contrato real y saltando de un lado a otro desde él. Aquí hay una prueba de concepto usando Huff.
Trucos obsoletos
1. external es más barato que public
Deberías seguir prefiriendo el modificador external por una cuestión de claridad si la función no puede ser llamada dentro del contrato, pero no tiene ningún efecto en el ahorro de gas.
2. != 0 es más barato que > 0
Alrededor de solidity 0.8.12 aproximadamente, esto dejó de ser cierto. Si te ves obligado a usar una versión antigua, aún puedes realizar un benchmark.
Malas Prácticas
Hay varios errores comunes que cometen los desarrolladores y que conducen a costos de gas más altos. Debido a las limitaciones de espacio, hemos publicado esta lista en un artículo separado.
Aprende más con RareSkills
El aprendizaje siempre es más efectivo cuando estás rodeado de una comunidad motivada y guiado por instructores experimentados. Este material forma parte de nuestro bootcamp avanzado de solidity bootcamp. ¡Si quieres practicar optimización de gas con otros profesionales de solidity bajo la guía de líderes de la industria, echa un vistazo al programa!
Publicado originalmente el 7 de septiembre de 2023