ABI cortas para la optimización de calldata
Introducción
En este artículo, aprenderá sobre los rollups optimistas, el costo de las transacciones en ellos y cómo esa estructura de costos diferente nos obliga a optimizar aspectos distintos a los de la red principal de Ethereum. También aprenderá a implementar esta optimización.
Aviso de transparencia
Soy empleado a tiempo completo de Optimismopens in a new tab, así que los ejemplos de este artículo se ejecutarán en Optimism. Sin embargo, la técnica explicada aquí debería funcionar igual de bien para otros rollups.
Terminología
Al debatir sobre los rollups, el término 'capa 1' (L1) se utiliza para la red principal, la red de producción de Ethereum. El término 'capa 2' (L2) se utiliza para el rollup o cualquier otro sistema que se basa en L1 para su seguridad, pero que realiza la mayor parte de su procesamiento fuera de la cadena.
¿Cómo podemos reducir aún más el costo de las transacciones en L2?
Los rollups optimistas tienen que preservar un registro de cada transacción histórica para que cualquiera pueda revisarlas y verificar que el estado actual es correcto. La forma más barata de obtener datos en la red principal de Ethereum es escribirlos como calldata. Esta solución fue elegida tanto por Optimismopens in a new tab como por Arbitrumopens in a new tab.
Costo de las transacciones en L2
El costo de las transacciones en L2 tiene dos componentes:
- Procesamiento en L2, que generalmente es extremadamente barato
- Almacenamiento en L1, que está vinculado a los costos de gas de la red principal
Mientras escribo esto, en Optimism el costo del gas de L2 es de 0,001 Gwei. El costo del gas de L1, por otro lado, es de aproximadamente 40 gwei. Puede ver los precios actuales aquíopens in a new tab.
Un byte de calldata cuesta 4 de gas (si es cero) o 16 de gas (si es cualquier otro valor). Una de las operaciones más caras en la EVM es escribir en el almacenamiento. El costo máximo de escribir una palabra de 32 bytes en el almacenamiento en L2 es de 22 100 de gas. Actualmente, esto es 22,1 gwei. Así que si podemos ahorrar un solo byte cero de calldata, podremos escribir unos 200 bytes en el almacenamiento y aun así saldremos ganando.
La ABI
La gran mayoría de las transacciones acceden a un contrato desde una cuenta de titularidad externa. La mayoría de los contratos están escritos en Solidity e interpretan su campo de datos según la interfaz binaria de la aplicación (ABI)opens in a new tab.
Sin embargo, la ABI fue diseñada para L1, donde un byte de calldata cuesta aproximadamente lo mismo que cuatro operaciones aritméticas, no para L2, donde un byte de calldata cuesta más de mil operaciones aritméticas. El calldata se divide así:
| Sección | Longitud | Bytes | Bytes desperdiciados | Gas desperdiciado | Bytes necesarios | Gas necesario |
|---|---|---|---|---|---|---|
| Selector de función | 4 | 0-3 | 3 | 48 | 1 | 16 |
| Ceros | 12 | 4-15 | 12 | 48 | 0 | 0 |
| Dirección de destino | 20 | 16-35 | 0 | 0 | 20 | 320 |
| Cantidad | 32 | 36-67 | 17 | 64 | 15 | 240 |
| Total | 68 | 160 | 576 |
Explicación:
- Selector de función: el contrato tiene menos de 256 funciones, por lo que podemos distinguirlas con un solo byte. Estos bytes suelen ser distintos de cero y, por lo tanto, cuestan dieciséis de gasopens in a new tab.
- Ceros: estos bytes siempre son cero porque una dirección de veinte bytes no requiere una palabra de treinta y dos bytes para contenerla.
Los bytes que valen cero cuestan cuatro de gas (consulte el Yellow Paperopens in a new tab, Apéndice G,
p. 27, el valor para
Gtxdatazero). - Cantidad: si asumimos que en este contrato
decimalses dieciocho (el valor normal) y la cantidad máxima de tokens que transferimos será de 1018, obtendremos una cantidad máxima de 1036. 25615 > 1036, por lo que quince bytes son suficientes.
Un desperdicio de 160 de gas en L1 es normalmente insignificante. Una transacción cuesta al menos 21 000 de gasopens in a new tab, por lo que un 0,8 % adicional no importa.
Sin embargo, en L2 las cosas son diferentes. Casi todo el costo de la transacción es por escribirla en L1.
Además del calldata de la transacción, hay 109 bytes de encabezado de transacción (dirección de destino, firma, etc.).
Por lo tanto, el costo total es de 109*16+576+160=2480, y estamos desperdiciando alrededor del 6,5 % de eso.
Reducir los costos cuando no se tiene el control del destino
Suponiendo que no tiene control sobre el contrato de destino, aún puede usar una solución similar a estaopens in a new tab. Repasemos los archivos relevantes.
Token.sol
Este es el contrato de destinoopens in a new tab.
Es un contrato ERC-20 estándar, con una característica adicional.
Esta función faucet permite a cualquier usuario obtener algunos tokens para su uso.
Haría inútil un contrato de producción ERC-20, pero hace la vida más fácil cuando existe un ERC-20 solo para facilitar las pruebas.
1 /**2 * @dev Proporciona al llamador 1000 tokens para que los use3 */4 function faucet() external {5 _mint(msg.sender, 1000);6 } // function faucetCalldataInterpreter.sol
Este es el contrato al que se supone que las transacciones deben llamar con un calldata más cortoopens in a new tab. Vamos a repasarlo línea por línea.
1//SPDX-License-Identifier: Unlicense2pragma solidity ^0.8.0;345import { OrisUselessToken } from "./Token.sol";Necesitamos la función del token para saber cómo llamarla.
1contract CalldataInterpreter {23 OrisUselessToken public immutable token;La dirección del token para el que somos un proxy.
1 /**2 * @dev Especifica la dirección del token3 * @param tokenAddr_ dirección del contrato ERC-204 */5 constructor(6 address tokenAddr_7 ) {8 token = OrisUselessToken(tokenAddr_);9 } // constructorMostrar todoLa dirección del token es el único parámetro que debemos especificar.
1 function calldataVal(uint startByte, uint length)2 private pure returns (uint) {Lee un valor del calldata.
1 uint _retVal;23 require(length < 0x21,4 "El límite de longitud de calldataVal es de 32 bytes");56 require(length + startByte <= msg.data.length,7 "calldataVal está intentando leer más allá de calldatasize");Vamos a cargar una sola palabra de 32 bytes (256 bits) en la memoria y a eliminar los bytes que no forman parte del campo que queremos. Este algoritmo no funciona para valores de más de 32 bytes y, por supuesto, no podemos leer más allá del final del calldata. En L1 podría ser necesario omitir estas pruebas para ahorrar gas, pero en L2 el gas es extremadamente barato, lo que permite cualquier comprobación de validez que se nos ocurra.
1 assembly {2 _retVal := calldataload(startByte)3 }Podríamos haber copiado los datos de la llamada a fallback() (véase más abajo), pero es más fácil usar Yulopens in a new tab, el lenguaje de ensamblado de la EVM.
Aquí usamos el código de operación CALLDATALOADopens in a new tab para leer los bytes desde startByte hasta startByte+31 en la pila.
En general, la sintaxis de un código de operación en Yul es <nombre del código de operación>(<primer valor de la pila, si lo hay>, <segundo valor de la pila, si lo hay>...).
12 _retVal = _retVal >> (256-length*8);Solo los bytes de length más significativos forman parte del campo, por lo que hacemos un desplazamiento a la derechaopens in a new tab para deshacernos de los otros valores.
Esto tiene la ventaja añadida de mover el valor a la derecha del campo, por lo que es el valor en sí mismo en lugar del valor multiplicado por 256algo.
12 return _retVal;3 }456 fallback() external {Cuando una llamada a un contrato de Solidity no coincide con ninguna de las firmas de función, llama a la función fallback()opens in a new tab (suponiendo que haya una).
En el caso de CalldataInterpreter, cualquier llamada llega aquí porque no hay otras funciones external o public.
1 uint _func;23 _func = calldataVal(0, 1);Lea el primer byte del calldata, que nos indica la función. Hay dos razones por las que una función no estaría disponible aquí:
- Las funciones que son
pureoviewno cambian el estado y no cuestan gas (cuando se llaman fuera de la cadena). No tiene sentido intentar reducir su costo de gas. - Funciones que dependen de
msg.senderopens in a new tab. El valor demsg.senderserá la dirección deCalldataInterpreter, no la del llamador.
Desafortunadamente, al observar las especificaciones de ERC-20opens in a new tab, esto solo deja una función, transfer.
Esto nos deja con solo dos funciones: transfer (porque podemos llamar a transferFrom) y faucet (porque podemos transferir los tokens de vuelta a quien nos llamó).
1 // Llamar a los métodos de cambio de estado del token usando2 // la información del calldata34 // faucet5 if (_func == 1) {Una llamada a faucet(), que no tiene parámetros.
1 token.faucet();2 token.transfer(msg.sender,3 token.balanceOf(address(this)));4 }Después de llamar a token.faucet(), obtenemos tokens. Sin embargo, como contrato proxy, no necesitamos tokens.
La EOA (cuenta de propiedad externa) o el contrato que nos llamó sí que los necesita.
Así que transferimos todos nuestros tokens a quien nos llamó.
1 // transferir (asumir que tenemos una asignación para ello)2 if (_func == 2) {La transferencia de tokens requiere dos parámetros: la dirección de destino y la cantidad.
1 token.transferFrom(2 msg.sender,Solo permitimos que los llamadores transfieran los tokens que poseen
1 address(uint160(calldataVal(1, 20))),La dirección de destino comienza en el byte n.º 1 (el byte n.º 0 es la función). Como dirección, tiene 20 bytes de longitud.
1 calldataVal(21, 2)Para este contrato en particular, asumimos que la cantidad máxima de tokens que alguien querría transferir cabe en dos bytes (menos de 65 536).
1 );2 }En general, una transferencia ocupa 35 bytes de calldata:
| Sección | Longitud | Bytes |
|---|---|---|
| Selector de función | 1 | 0 |
| Dirección de destino | 32 | 1-32 |
| Cantidad | 2 | 33-34 |
1 } // fallback23} // contract CalldataInterpretertest.js
Este test unitario de JavaScriptopens in a new tab nos muestra cómo usar este mecanismo (y cómo verificar que funciona correctamente). Voy a suponer que entiende chaiopens in a new tab y ethersopens in a new tab y solo explicaré las partes que se aplican específicamente al contrato.
1const { expect } = require("chai");23describe("CalldataInterpreter", function () {4 it("Debería dejarnos usar tokens", async function () {5 const Token = await ethers.getContractFactory("OrisUselessToken")6 const token = await Token.deploy()7 await token.deployed()8 console.log("Dirección del token:", token.address)910 const Cdi = await ethers.getContractFactory("CalldataInterpreter")11 const cdi = await Cdi.deploy(token.address)12 await cdi.deployed()13 console.log("Dirección de CalldataInterpreter:", cdi.address)1415 const signer = await ethers.getSigner()Mostrar todoComenzamos por implementar ambos contratos.
1 // Obtener tokens para usar2 const faucetTx = {No podemos usar las funciones de alto nivel que normalmente usaríamos (como token.faucet()) para crear transacciones, porque no seguimos la ABI.
En cambio, tenemos que construir la transacción nosotros mismos y luego enviarla.
1 to: cdi.address,2 data: "0x01"Hay dos parámetros que debemos proporcionar para la transacción:
to, la dirección de destino. Este es el contrato intérprete de calldata.data, el calldata que se enviará. En el caso de una llamada de faucet, los datos son un solo byte,0x01.
12 }3 await (await signer.sendTransaction(faucetTx)).wait()Llamamos al método sendTransaction del firmanteopens in a new tab porque ya hemos especificado el destino (faucetTx.to) y necesitamos que se firme la transacción.
1// Comprobar que el faucet proporciona los tokens correctamente2expect(await token.balanceOf(signer.address)).to.equal(1000)Aquí verificamos el saldo.
No es necesario ahorrar gas en las funciones view, por lo que las ejecutamos con normalidad.
1// Dar al CDI una asignación (las aprobaciones no se pueden intermediar)2const approveTX = await token.approve(cdi.address, 10000)3await approveTX.wait()4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)Otorgue al intérprete de calldata una asignación para poder realizar transferencias.
1// Transferir tokens2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"3const transferTx = {4 to: cdi.address,5 data: "0x02" + destAddr.slice(2, 42) + "0100",6}Cree una transacción de transferencia. El primer byte es «0x02», seguido de la dirección de destino y, finalmente, la cantidad (0x0100, que es 256 en decimal).
1 await (await signer.sendTransaction(transferTx)).wait()23 // Comprobar que tenemos 256 tokens menos4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)56 // Y que nuestro destino los recibió7 expect (await token.balanceOf(destAddr)).to.equal(256)8 }) // it9}) // describeMostrar todoReducir el costo cuando se tiene el control del contrato de destino
Si controla el contrato de destino, puede crear funciones que omitan las verificaciones de msg.sender porque confían en el intérprete del calldata.
Puede ver un ejemplo de cómo funciona esto aquí, en la rama control-contractopens in a new tab.
Si el contrato solo respondiera a transacciones externas, podríamos arreglárnoslas teniendo solo un contrato. Sin embargo, eso rompería la componibilidad. Es mucho mejor tener un contrato que responda a llamadas normales ERC-20 y otro contrato que responda a transacciones con llamadas de datos cortas.
Token.sol
En este ejemplo podemos modificar Token.sol.
Esto nos permite tener un número de funciones a las que solo puede llamar el proxy.
Estas son las nuevas partes:
1 // La única dirección a la que se le permite especificar la dirección de CalldataInterpreter2 address owner;34 // La dirección de CalldataInterpreter5 address proxy = address(0);El contrato ERC-20 necesita conocer la identidad del proxy autorizado. Sin embargo, no podemos establecer esta variable en el constructor, porque aún no conocemos el valor. Este contrato se instancia primero porque el proxy espera la dirección del token en su constructor.
1 /**2 * @dev Llama al constructor de ERC20.3 */4 constructor(5 ) ERC20("Oris useless token-2", "OUT-2") {6 owner = msg.sender;7 }La dirección del creador (llamada owner) se almacena aquí porque esa es la única dirección a la que se le permite establecer el proxy.
1 /**2 * @dev establece la dirección para el proxy (el CalldataInterpreter).3 * Solo puede ser llamado una vez por el propietario4 */5 function setProxy(address _proxy) external {6 require(msg.sender == owner, "Solo puede ser llamado por el propietario");7 require(proxy == address(0), "El proxy ya está configurado");89 proxy = _proxy;10 } // function setProxyMostrar todoEl proxy tiene acceso privilegiado, porque puede omitir las revisiones de seguridad.
Para asegurarnos de que podamos confiar en el proxy, solo le permitimos a owner llamar a esta función y solo una vez.
Una vez que proxy tenga un valor real (diferente a cero), ese valor no puede cambiar, así que incluso si el propietario decide volverse malicioso o se revela su mnemónico, seguimos estando seguros.
1 /**2 * @dev Algunas funciones solo pueden ser llamadas por el proxy.3 */4 modifier onlyProxy {Esta es una función modifieropens in a new tab, que modifica el funcionamiento de otras funciones.
1 require(msg.sender == proxy);Primero, verificamos que nos ha llamado el proxy y ningún otro.
Si no, revert.
1 _;2 }En caso de ser así, ejecutamos la función que modificamos.
1 /* Funciones que permiten que el proxy realmente represente a las cuentas */23 function transferProxy(address from, address to, uint256 amount)4 public virtual onlyProxy() returns (bool)5 {6 _transfer(from, to, amount);7 return true;8 }910 function approveProxy(address from, address spender, uint256 amount)11 public virtual onlyProxy() returns (bool)12 {13 _approve(from, spender, amount);14 return true;15 }1617 function transferFromProxy(18 address spender,19 address from,20 address to,21 uint256 amount22 ) public virtual onlyProxy() returns (bool)23 {24 _spendAllowance(from, spender, amount);25 _transfer(from, to, amount);26 return true;27 }Mostrar todoEsas son tres operaciones que normalmente requieren que el mensaje provenga directamente de la entidad que transfiere los tokens o aprueba una autorización. Aquí tenemos una versión proxy de estas operaciones que:
- Es modificada por
onlyProxy()para que nadie más pueda controlarlas. - Obtiene la dirección que normalmente sería
msg.sendercomo un parámetro adicional.
CalldataInterpreter.sol
El intérprete de calldata es casi idéntico al anterior, excepto que las funciones intermediadas reciben un parámetro msg.sender y no es necesaria una asignación para transfer.
1 // transferir (no se necesita asignación)2 if (_func == 2) {3 token.transferProxy(4 msg.sender,5 address(uint160(calldataVal(1, 20))),6 calldataVal(21, 2)7 );8 }910 // aprobar11 if (_func == 3) {12 token.approveProxy(13 msg.sender,14 address(uint160(calldataVal(1, 20))),15 calldataVal(21, 2)16 );17 }1819 // transferFrom20 if (_func == 4) {21 token.transferFromProxy(22 msg.sender,23 address(uint160(calldataVal( 1, 20))),24 address(uint160(calldataVal(21, 20))),25 calldataVal(41, 2)26 );27 }Mostrar todoTest.js
Hay algunos cambios entre el anterior código de prueba y este.
1const Cdi = await ethers.getContractFactory("CalldataInterpreter")2const cdi = await Cdi.deploy(token.address)3await cdi.deployed()4await token.setProxy(cdi.address)Necesitamos decirle al contrato ERC-20 en cuál proxy debe confiar.
1console.log("Dirección de CalldataInterpreter:", cdi.address)23// Necesitamos dos firmantes para verificar las asignaciones4const signers = await ethers.getSigners()5const signer = signers[0]6const poorSigner = signers[1]Para verificar approve() y transferFrom(), necesitamos un segundo firmante.
Lo llamaremos poorSigner porque no obtiene ninguno de nuestros tokens (por supuesto, es necesario contar con ETH).
1// Transferir tokens2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"3const transferTx = {4 to: cdi.address,5 data: "0x02" + destAddr.slice(2, 42) + "0100",6}7await (await signer.sendTransaction(transferTx)).wait()Debido a que el contrato ERC-20 confía en el proxy (cdi), no necesitamos una asignación para transmitir transferencias.
1// aprobación y transferFrom2const approveTx = {3 to: cdi.address,4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",5}6await (await signer.sendTransaction(approveTx)).wait()78const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"910const transferFromTx = {11 to: cdi.address,12 data: "0x04" + signer.address.slice(2, 42) + destAddr2.slice(2, 42) + "00FF",13}14await (await poorSigner.sendTransaction(transferFromTx)).wait()1516// Comprobar que la combinación de aprobación y transferFrom se realizó correctamente17expect(await token.balanceOf(destAddr2)).to.equal(255)Mostrar todoPruebe las dos nuevas funciones.
Tenga en cuenta que transferFromTx requiere dos parámetros de dirección: quien proporciona la asignación y el receptor.
Conclusión
Tanto Optimismopens in a new tab como Arbitrumopens in a new tab están buscando formas de reducir el tamaño del calldata escrito en L1 y, por lo tanto, el costo de las transacciones. Sin embargo, como proveedores de infraestructura que buscamos soluciones genéricas, nuestras habilidades están limitadas. Como desarrollador de dapp, tiene conocimiento específico de la aplicación, lo que le permite optimizar su calldata mejor que nosotros en una solución genérica. Esperamos que este artículo pueda ayudarle a encontrar la solución ideal a sus necesidades.
Vea aquí más de mi trabajoopens in a new tab.
Última actualización de la página: 22 de agosto de 2025