Ir al contenido principal

ABI cortas para la optimización de calldata

capa 2
Intermedio
Ori Pomerantz
1 de abril de 2022
15 minuto leído

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:

  1. Procesamiento en L2, que generalmente es extremadamente barato
  2. 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ónLongitudBytesBytes desperdiciadosGas desperdiciadoBytes necesariosGas necesario
Selector de función40-3348116
Ceros124-15124800
Dirección de destino2016-350020320
Cantidad3236-67176415240
Total68160576

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 decimals es 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 use
3 */
4 function faucet() external {
5 _mint(msg.sender, 1000);
6 } // function faucet

CalldataInterpreter.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: Unlicense
2pragma solidity ^0.8.0;
3
4
5import { OrisUselessToken } from "./Token.sol";

Necesitamos la función del token para saber cómo llamarla.

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;

La dirección del token para el que somos un proxy.

1 /**
2 * @dev Especifica la dirección del token
3 * @param tokenAddr_ dirección del contrato ERC-20
4 */
5 constructor(
6 address tokenAddr_
7 ) {
8 token = OrisUselessToken(tokenAddr_);
9 } // constructor
Mostrar todo

La 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;
2
3 require(length < 0x21,
4 "El límite de longitud de calldataVal es de 32 bytes");
5
6 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>...).

1
2 _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.

1
2 return _retVal;
3 }
4
5
6 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;
2
3 _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í:

  1. Las funciones que son pure o view no cambian el estado y no cuestan gas (cuando se llaman fuera de la cadena). No tiene sentido intentar reducir su costo de gas.
  2. Funciones que dependen de msg.senderopens in a new tab. El valor de msg.sender será la dirección de CalldataInterpreter, 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 usando
2 // la información del calldata
3
4 // faucet
5 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ónLongitudBytes
Selector de función10
Dirección de destino321-32
Cantidad233-34
1 } // fallback
2
3} // contract CalldataInterpreter

test.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");
2
3describe("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)
9
10 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)
14
15 const signer = await ethers.getSigner()
Mostrar todo

Comenzamos por implementar ambos contratos.

1 // Obtener tokens para usar
2 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:

  1. to, la dirección de destino. Este es el contrato intérprete de calldata.
  2. data, el calldata que se enviará. En el caso de una llamada de faucet, los datos son un solo byte, 0x01.
1
2 }
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 correctamente
2expect(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 tokens
2const 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()
2
3 // Comprobar que tenemos 256 tokens menos
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // Y que nuestro destino los recibió
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe
Mostrar todo

Reducir 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 CalldataInterpreter
2 address owner;
3
4 // La dirección de CalldataInterpreter
5 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 propietario
4 */
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");
8
9 proxy = _proxy;
10 } // function setProxy
Mostrar todo

El 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 */
2
3 function transferProxy(address from, address to, uint256 amount)
4 public virtual onlyProxy() returns (bool)
5 {
6 _transfer(from, to, amount);
7 return true;
8 }
9
10 function approveProxy(address from, address spender, uint256 amount)
11 public virtual onlyProxy() returns (bool)
12 {
13 _approve(from, spender, amount);
14 return true;
15 }
16
17 function transferFromProxy(
18 address spender,
19 address from,
20 address to,
21 uint256 amount
22 ) public virtual onlyProxy() returns (bool)
23 {
24 _spendAllowance(from, spender, amount);
25 _transfer(from, to, amount);
26 return true;
27 }
Mostrar todo

Esas 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:

  1. Es modificada por onlyProxy() para que nadie más pueda controlarlas.
  2. Obtiene la dirección que normalmente sería msg.sender como 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 }
9
10 // aprobar
11 if (_func == 3) {
12 token.approveProxy(
13 msg.sender,
14 address(uint160(calldataVal(1, 20))),
15 calldataVal(21, 2)
16 );
17 }
18
19 // transferFrom
20 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 todo

Test.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)
2
3// Necesitamos dos firmantes para verificar las asignaciones
4const 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 tokens
2const 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 transferFrom
2const approveTx = {
3 to: cdi.address,
4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",
5}
6await (await signer.sendTransaction(approveTx)).wait()
7
8const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"
9
10const 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()
15
16// Comprobar que la combinación de aprobación y transferFrom se realizó correctamente
17expect(await token.balanceOf(destAddr2)).to.equal(255)
Mostrar todo

Pruebe 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

¿Le ha resultado útil este tutorial?