Introducción
Una transacción con lista de acceso de Ethereum permite ahorrar gas en llamadas entre contratos al declarar de antemano a qué contrato y ranuras de almacenamiento se accederá. Se puede ahorrar hasta 100 de gas por cada ranura de almacenamiento a la que se acceda.
La motivación para introducir este EIP fue mitigar los cambios disruptivos en el EIP 2929, que aumentaron el costo de acceso al almacenamiento en frío. El EIP 2929 corrigió las operaciones de acceso al almacenamiento con precios demasiado bajos, que podían llevar a ataques de denegación de servicio. Sin embargo, el aumento del costo de acceso al almacenamiento en frío rompió algunos contratos inteligentes, por lo que se introdujo el EIP 2930: Optional Access Lists para mitigar esto.
Para desbloquear estos contratos, se introdujo el EIP 2930, lo que permitió “precalentar” las ranuras de almacenamiento. No es coincidencia que el EIP 2929 y el EIP 2930 sean contiguos.
Autoría
Este artículo fue coescrito por Jesse Raymond (LinkedIn, Twitter), investigador de blockchain en RareSkills. Para apoyar artículos gratuitos de alta calidad como este, y para aprender conceptos más avanzados de desarrollo en Ethereum, por favor consulta nuestro Solidity Bootcamp.
Cómo funciona
Una transacción EIP-2930 se lleva a cabo de la misma manera que cualquier otra transacción, excepto que el costo de almacenamiento en frío se paga por adelantado con un descuento, en lugar de durante la ejecución de la operación SLOAD.
No requiere ninguna modificación en el código Solidity y se especifica puramente en el lado del cliente.
La tarifa paga por adelantado el acceso en frío de la ranura de almacenamiento para que, durante la ejecución real, solo se pague la tarifa en caliente. Cuando las claves de almacenamiento se conocen de antemano, los clientes del nodo de Ethereum pueden obtener previamente los valores de almacenamiento, lo que permite cierta paralelización entre el cálculo y el acceso al almacenamiento.
El EIP-2930 no impide el acceso al almacenamiento fuera de la lista de acceso; colocar una combinación de dirección-almacenamiento en la lista de acceso no es un compromiso de usarla. Sin embargo, el resultado sería pagar por adelantado la carga de almacenamiento en frío sin ningún propósito.
Cobrar menos por el acceso
Según el EIP 2930, la bifurcación dura de Berlin aumentó el costo “en frío” de los códigos de operación de acceso a cuentas (como BALANCE, todos los CALL y EXT\*) a 2600 y aumentó el costo “en frío” del código de operación de acceso al estado (SLOAD) de 800 a 2100, mientras que redujo el costo “en caliente” para ambos a 100.
Sin embargo, el EIP-2930 tiene el beneficio adicional de reducir los costos de transacción debido al descuento de 200 de gas de la transacción.
Como resultado, en lugar de pagar 2600 y 2100 de gas por un CALL y un SLOAD respectivamente, la transacción solo requiere 2400 y 1900 de gas para el acceso en frío, y el acceso en caliente subsecuente solo costará 100 de gas.
Implementación de una transacción con lista de acceso
En esta sección, implementaremos una lista de acceso, compararemos una transacción típica con una transacción EIP-2930 y proporcionaremos algunos puntos de referencia de gas.
Echemos un vistazo al contrato al que llamaremos.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
contract Calculator {
uint public x = 20;
uint public y = 20;
function getSum() public view returns (uint256) {
return x + y;
}
}
contract Caller {
Calculator calculator;
constructor(address \_calc) {
calculator = Calculator(\_calc);
}
// call the getSum function in the calculator contract
function callCalculator() public view returns (uint sum) {
sum = calculator.getSum();
}
}
Desplegaremos e interactuaremos con los contratos en el nodo local de hardhat con el siguiente script.
import { ethers } from "hardhat";
async function main() {
const [user] = await ethers.getSigners();
const data = "0xf4acc7b5"; // function selector for `callCalculator()`
const Calculator = await ethers.getContractFactory("Calculator");
const calculator = await Calculator.deploy();
await calculator.deployed();
console.log(`Calc contract deployed to ${calculator.address}`);
const Caller = await ethers.getContractFactory("Caller");
const caller = await Caller.deploy(calculator.address);
await caller.deployed();
console.log(`Caller contract deployed to ${caller.address}`);
const tx1 = {
from: user.address,
to: caller.address,
data: data,
value: 0,
type: 1,
accessList: [
{
address: calculator.address,
storageKeys: [
"0x0000000000000000000000000000000000000000000000000000000000000000",
"0x0000000000000000000000000000000000000000000000000000000000000001",
],
},
],
};
const tx2 = {
from: user.address,
to: caller.address,
data: data,
value: 0,
};
console.log("============== transaction with access list ==============");
const txCall = await user.sendTransaction(tx1);
const receipt = await txCall.wait();
console.log(
`gas cost for tx with access list: ${receipt.gasUsed.toString()}`
);
console.log("============== transaction without access list ==============");
const txCallNA = await user.sendTransaction(tx2);
const receiptNA = await txCallNA.wait();
console.log(
`gas cost for tx without access list: ${receiptNA.gasUsed.toString()}`
);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
La sección type con el valor de 1 justo encima de la lista de acceso especifica que la transacción es una transacción de lista de acceso.
El accessList es un arreglo de objetos que contienen la dirección y las ranuras de almacenamiento a las que accederá la transacción.
Las ranuras de almacenamiento o storageKeys como se definen en el código deben ser un valor de 32 bytes; por esto tenemos muchos ceros a la izquierda allí.
Tenemos valores de 32 bytes para cero y uno como claves de almacenamiento porque la función getSum que llamamos a través del contrato Caller accede exactamente a estas ranuras de almacenamiento en el contrato Calculator. Específicamente, x está en la ranura de almacenamiento cero e y está en la ranura de almacenamiento uno.
Resultados
Obtenemos el siguiente resultado
Compiled 1 Solidity file successfully
Calc contract deployed to 0x5FbDB2315678afecb367f032d93F642f64180aa3
Caller contract deployed to 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
============== transaction with access list ==============
gas cost for tx with access list: 30934
============== transaction without access list ==============
gas cost for tx without access list: 31234
Podemos ver que ahorramos 300 de gas (esto será cierto sin importar la configuración del optimizador).
La llamada al contrato externo ahorró 200 de gas, y los dos accesos de almacenamiento ahorraron 200 cada uno, lo que lleva a un ahorro potencial de 600. Sin embargo, el acceso en caliente aún debe pagarse, y hay un acceso en caliente para la llamada externa y las dos variables de almacenamiento, cada una de estas tres operaciones costando 100 de gas cada una. Por lo tanto, el ahorro neto es de 300 de gas.
Para ser específicos, la fórmula en nuestro ejemplo se calcula de la siguiente manera:
Los costos de acceso habrían sido 2600 + 2100 2 = 6800 de gas sin la lista de acceso.
Pero debido a que pagamos por adelantado 2400 + 1900 2 = 6200 de gas por la lista de acceso, solo pagamos 100 + 100 2 = 300 de gas por el acceso en caliente. Así que pagamos 6200 + 300 = 6500 de gas, cuando habríamos gastado 6800 de gas, lo que lleva a un ahorro neto de 300 de gas.
Obtener las ranuras de almacenamiento de una transacción con lista de acceso
El cliente Go-Ethereum (geth) tiene el método rpc eth_createAccessList para determinar convenientemente la ranura de almacenamiento (consulta la API de web3.js como ejemplo).
Con el método RPC, el cliente determina las ranuras de almacenamiento a las que se accedió y devuelve la lista de acceso.
También podemos usar este método RPC en foundry con el comando cast access-list, que utiliza el eth_createAccessList en segundo plano y devuelve la lista de acceso.
Probemos un ejemplo a continuación; interactuaremos con el contrato factory de UniswapV2 (en la red Göerli) llamando a la función “allPairs”, que devuelve un contrato de par desde un arreglo basado en el índice pasado.
Ejecutamos el siguiente comando en una bifurcación de la testnet de Göerli.
cast access-list 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f "allPairs(uint256)" 0
Esto devolverá la lista de acceso de la transacción, y se vería así en nuestra terminal si tuviera éxito.
gas used: 27983 // amount of gas used by the transaction
access-list:
- address: 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f // address of the uniswapv2 factory
keys:
0xc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b // slot of the pair address
0x0000000000000000000000000000000000000000000000000000000000000003 // slot of the array length
Ejemplo de desperdicio de gas con listas de acceso
Si la ranura de almacenamiento se calcula incorrectamente, la transacción pagará el depósito por la lista de acceso y no obtendrá ningún beneficio de ello. En el siguiente ejemplo, mediremos una transacción con lista de acceso de ethereum calculada incorrectamente.
El siguiente punto de referencia pagará por adelantado la ranura 1 cuando en realidad es la ranura 0 la que se utiliza.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
contract Wrong {
uint256 private x = 1;
function getX() public view returns (uint256) {
return x;
}
}
Pongamos esto a prueba. Llamaremos a la función getX() utilizando una lista de acceso con una ranura de almacenamiento incorrecta y luego la compararemos con una transacción normal que no especifica una lista de acceso.
Este es el script para desplegar y ejecutar el contrato en el nodo local de hardhat.
import { ethers } from "hardhat";
async function main() {
const [user] = await ethers.getSigners();
const data = "0x5197c7aa"; // function selector for the `getX` function
const Slot = await ethers.getContractFactory("Wrong");
const slot = await Slot.deploy();
await slot.deployed();
console.log(`Slot contract deployed to ${slot.address}`);
const badtx = {
from: user.address,
// to: calculator.address,
to: slot.address,
data: data,
value: 0,
type: 1,
accessList: [
{
address: slot.address,
storageKeys: [
"0x0000000000000000000000000000000000000000000000000000000000000001", // wrong slot number
],
},
],
};
const badTxResult = await user.sendTransaction(badtx);
const badTxReceipt = await badTxResult.wait();
console.log(
`gas cost for incorrect access list: ${badTxReceipt.gasUsed.toString()}`
);
const normaltx = {
from: user.address,
// to: calculator.address,
to: slot.address,
data: data,
value: 0,
};
const normalTxResult = await user.sendTransaction(normaltx);
const normalTxReceipt = await normalTxResult.wait();
console.log(
`gas cost for tx without access list: ${normalTxReceipt.gasUsed.toString()}`
);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Los resultados son los siguientes
Slot contract deployed to 0x5FbDB2315678afecb367f032d93F642f64180aa3
gas cost for incorrect access list: 27610
gas cost for tx without access list: 23310
La transacción se realizó con éxito a pesar de que teníamos la ranura de almacenamiento incorrecta, sin embargo, habría sido más barato no usar una lista de acceso en lugar de usar una calculada incorrectamente.
No uses listas de acceso cuando las ranuras de almacenamiento no sean deterministas
La implicación de la sección anterior es que las listas de acceso no deben usarse cuando las ranuras de almacenamiento a las que se accede no son deterministas.
Por ejemplo, si usamos un número de ranura de almacenamiento determinado en base a un cierto número de bloque, la ranura de almacenamiento generalmente no será predecible.
Otro ejemplo son las ranuras de almacenamiento que dependen de cuándo ocurrió la transacción. Algunas implementaciones de ERC-721 agregan las direcciones de los propietarios a un arreglo y usan el índice del arreglo para identificar la propiedad del NFT. Como resultado, la ranura de almacenamiento para un token depende del orden en el que los usuarios acuñaron y eso no se puede predecir.
¿Cuándo ahorra gas la lista de acceso?
Siempre que hagas una llamada entre contratos, considera usar una transacción con lista de acceso
Hacer una llamada entre contratos normalmente incurre en 2600 de gas adicionales, pero usar una transacción con lista de acceso cuesta 2400 y precalienta el acceso al contrato para que solo cobre 100 de gas, lo que significa que el costo neto pasa de 2600 a 2500.
Esto también se aplica al acceder a variables de almacenamiento en otro contrato. Normalmente cuesta 2100 por el acceso en frío, pero una transacción con lista de acceso paga 1900 de gas para precalentar la ranura de almacenamiento, lo que lleva a un ahorro neto de 100 de gas.
Proporcionamos más ejemplos de transacciones con listas de acceso para llamadas comunes entre contratos, tales como:
- acceder al precio en un oráculo de Chainlink,
- un proxy haciendo un delegatecall a un contrato de implementación
- hacer una transferencia ERC-20 a través de una llamada de contrato a contrato
en este repositorio.
Cuándo no usar una transacción con lista de acceso
No hay una ‘tarifa adicional’ por llamar directamente a un contrato inteligente, está incluida en los 21,000 de gas que todas las transacciones deben pagar. Por lo tanto, las listas de acceso no proporcionan ningún beneficio para las transacciones que solo acceden a un contrato inteligente.
Conclusión
Las transacciones con lista de acceso de Ethereum EIP-2930 son una forma rápida de ahorrar hasta 200 de gas por ranura de almacenamiento cuando la dirección y la ranura de almacenamiento de una llamada entre contratos se pueden predecir. No deben usarse cuando no se realizan llamadas entre contratos o cuando el par de dirección y ranura de almacenamiento no es determinista.
Aprende más
Para conceptos más avanzados de Solidity, consulta nuestro Solidity Bootcamp.
Publicado originalmente el 27 de marzo de 2023