Ir al contenido principal

ABIs cortas para la Optimización de Calldata

capa 2
Intermediate
Ori Pomerantz
1 de abril de 2022
15 minuto leído minute read

Introducción

En este artículo aprenderá sobre optimistic rollups, el coste de transacciones en ellos, y cómo esa estructura de coste diferente requiere que optimicemos para cosas diferentes que en la Red principal de Ethereum. También aprenderá a implementar esta optimización.

Divulgación completa

Soy un empleado a tiempo completo de Optimism(opens 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

Cuando se discuten rollups, el término 'capa 1' (L1) se utiliza para Mainnet, la red de producción Ethereum. El término 'capa 2' (L2) se utiliza para rollup o cualquier otro sistema que se base en L1 para la seguridad, pero realiza la mayor parte de su procesamiento fuera de cadena.

¿Cómo podemos reducir aún más el coste de las transacciones en L2?

Los Optimistic rollups tienen que preservar un registro de cada transacción histórica para que cualquiera pueda pasar por ellos y verificar que el estado actual es correcto. La forma más barata de obtener datos en el Ethereum Mainnet es escribirlos como datos de llamada. Esta solución fue elegida por Optimism(opens in a new tab) y Arbitrum(opens in a new tab).

Coste de transacciones en L2

El coste de las transacciones en L2 se compone de dos componentes:

  1. Procesamiento en L2, que generalmente es extremadamente barato
  2. Almacenamiento L1, que está vinculado a los costes de gas de La Red Principal

Mientras escribo esto, en Optimism el costo del gas en L2 es 0.001 Gwei. Por otra parte, el coste del gas en L1 es de aproximadamente 40 gwei. Puedes 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 coste máximo de escribir una palabra de 32 bytes para almacenar en L2 es de 22100 gas. Actualmente, esto es 22.1 gwei. Así que si podemos guardar un único byte cero de calldata, podremos escribir unos 200 bytes en el almacenamiento y aún así saldremos adelante.

El ABI

La gran mayoría de las transacciones acceden a un contrato desde una cuenta de propiedad externa. La mayoría de los contratos están escritos en Solidity e interpretan su campo de datos por la interfaz binaria de la aplicación (ABI)(opens in a new tab).

Sin embargo, el ABI fue diseñado para L1, donde un byte de datos de llamada cuesta aproximadamente lo mismo que cuatro operaciones aritméticas, y no para L2, donde un byte de datos de llamada cuesta más de mil operaciones aritméticas. Por ejemplo, aquí está una transacción de transferencia de ERC-20(opens in a new tab). Los datos de llamada se dividen así:

SecciónLongitudBytesBytes consumidosGas consumidoBytes necesariosGas necesario
Selector de funciones40-3348116
Ceros124-15124800
Dirección de destino2016-350020320
Monto3236-67176415240
Total68160576

Explicación:

  • Function selector: Contrato con menos de 256 funciones, para que podamos distinguirlos con un solo byte. Estos bytes suelen ser distintos de cero y, por lo tanto, cuesta dieciséis de gas(opens in a new tab).
  • Ceros: estos bytes son siempre cero porque una dirección de veinte bytes no requiere una palabra de treinta y dos bytes para contenerla. Bytes que contienen cuatro gas de costo cero (ver el yellow paper(opens in a new tab), Apéndice G, pag. 27, el valor de Gtxdatazero).
  • Amount: Si asumimos que en este contrato decimals es dieciocho (el valor normal) y la cantidad máxima de tokens que transferiremos será 1018, obtenemos una cantidad máxima de 1036. 25615 > 1036, entonces quince bytes serán suficientes.

Un desperdicio de 160 en gas en L1 normalmente es insignificante. Una transacción cuesta al menos 21.000 de gas(opens 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 escrito en L1. Además de los datos de llamada 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 109*16+576+160=2480, y estamos desperdiciando alrededor del 6,5 % de eso.

Reducir costes cuando no controlas el destino

Suponiendo que no tienes control sobre el contrato de destino, aún puedes usar una solución similar a ésta(opens in a new tab). Repasemos los archivos relevantes.

Token.sol

Este es el contrato de destino(opens 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 algún token para usar. Haría inútil un contrato de producción de ERC-20, pero hace la vida más fácil cuando existe un ERC-20 sólo para facilitar las pruebas.

1 /**
2 * @dev Gives the caller 1000 tokens to play with
3 */
4 function faucet() external {
5 _mint(msg.sender, 1000);
6 } // function faucet
Copiar

Puede ver un ejemplo de la implementación de este contrato aquí(opens in a new tab).

CalldataInterpreter.sol

Este es el contrato al que se supone que deben llamar las transacciones con datos de llamada más cortos(opens 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";
Copiar

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

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;
Copiar

La dirección del token del que somos proxy.

1
2 /**
3 * @dev Specify the token address
4 * @param tokenAddr_ ERC-20 contract address
5 */
6 constructor(
7 address tokenAddr_
8 ) {
9 token = OrisUselessToken(tokenAddr_);
10 } // constructor
Mostrar todo
Copiar

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) {
Copiar

Leer el valor desde el calldata.

1 uint _retVal;
2
3 require(length < 0x21,
4 "calldataVal length limit is 32 bytes");
5
6 require(length + startByte <= msg.data.length,
7 "calldataVal trying to read beyond calldatasize");
Copiar

Vamos a cargar una sola palabra de 32 bytes (256 bits) en la memoria y 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 de los datos de llamada. En L1 puede que sea necesario saltarse estas pruebas para ahorrar gasolina, pero en L2 el gas es extremadamente barato, lo que permite realizar cualquier control de seguridad que podamos imaginar.

1 assembly {
2 _retVal := calldataload(startByte)
3 }
Copiar

Podríamos haber copiado los datos de la llamada a fallback() (ver más abajo), pero es más fácil usar Yul(opens in a new tab), el lenguaje ensamblador del EVM.

Aquí usamos el código de operación CALLDATALOAD(opens in a new tab) para leer los bytes startByte a 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 pila, si lo hay>,<segundo valor de pila, si lo hay>...).

1
2 _retVal = _retVal >> (256-length*8);
Copiar

Sólo los bytes de length más significativos forman parte del campo, por lo que desplazamiento a la derecha(opens in a new tab) para obtener deshacerse de los otros valores. Esto tiene la ventaja adicional 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 {
Copiar

Cuando una llamada a un contrato de Solidity no coincide con ninguna de las firmas de función, llama la función fallback()(opens in a new tab) (suponiendo que exista una). En el caso de CalldataInterpreter, cualquier llamada llega aquí porque no hay ningún otro external o public funciones.

1 uint _func;
2 _func = calldataVal(0, 1);
Copiar

Lea el primer byte del calldata, que nos dice 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 tratar de reducir su costo de gas.
  2. Funciones que dependen de

msg.sender. El valor de msg.sender será la dirección de CalldataInterpreter, no la persona que llama.

Desafortunadamente, mirando las especificaciones ERC-20(opens in a new tab), esto deja solo una función, transfer. Esto nos deja con sólo dos funciones: transfer (porque podemos llamar a transferFrom) y faucet (porque podemos transferir los tokens a quien haya llamado a nosotros).

1
2 // Call the state changing methods of token using
3 // information from the calldata
4
5 // faucet
6 if (_func == 1) {
Copiar

Una llamada a faucet(), que no tiene parámetros.

1 token.faucet();
2 token.transfer(msg.sender,
3 token.balanceOf(address(this)));
4 }
Copiar

Después de llamar a token.faucet() obtenemos tokens. Sin embargo, como contrato de proxy, no necesitamos tokens. El EOA (externally owned account) o o contrato que nos llama lo hace. Entonces transferimos todos nuestros tokens a quien nos llamó.

1 // transfer (assume we have an allowance for it)
2 if (_func == 2) {
Copiar

La transferencia de tokens requiere dos parámetros: la dirección de destino y la cantidad.

1 token.transferFrom(
2 msg.sender,
Copiar

Solo permitimos que las personas que llaman transfieran tokens de su propiedad

1 address(uint160(calldataVal(1, 20))),
Copiar

La dirección de destinación empieza en byte #1 (byte #0 es la función). Como una dirección, es de 20 bytes de largo.

1 calldataVal(21, 2)
Copiar

Por este contrato en particular asumimos que el número máximo de tokens que alguien querría transferir en 2 bytes (menos que 65536).

1 );
2 }
Copiar

En general, una transferencia toma 35 bytes de datos de llamada:

SecciónLongitudBytes
Selector de funciones10
Dirección de destino321-32
Monto233-34
1 } // fallback
2
3} // contract CalldataInterpreter
Copiar

prueba.js

Esta unidad de prueba de JavaScript(opens in a new tab) nos muestra cómo usar este mecanismo (y cómo verificar si funciona correctamente). Voy a asumir que entendiste chai(opens in a new tab) y ethers(opens in a new tab) y solo voy a explicar específicamente las partes para aplicar un contrato.

1const { expect } = require("chai");
2
3describe("CalldataInterpreter", function () {
4 it("Should let us use tokens", async function () {
5 const Token = await ethers.getContractFactory("OrisUselessToken")
6 const token = await Token.deploy()
7 await token.deployed()
8 console.log("Token addr:", 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("CalldataInterpreter addr:", cdi.address)
14
15 const signer = await ethers.getSigner()
Mostrar todo
Copiar

Empezamos implementando ambos contratos.

1 // Get tokens to play with
2 const faucetTx = {

No podemos usar las funciones de alto nivel que normalmente usamos (como token.faucet()) para crear transacciones, porque no seguimos el ABI. En cambio, tenemos que construir la transacción por nuestra cuenta y después enviarla.

1 to: cdi.address,
2 data: "0x01"

Hay dos parámetros necesarios 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 por enviar. En caso de un llamado de grifo, la información es un único byte, 0x01.

1
2 }
3 await (await signer.sendTransaction(faucetTx)).wait()

Llamamos al método sendTransaction del firmante(opens in a new tab) porque ya especificamos el destino (faucetTx.to) y necesitamos que la transacción sea firmada.

1// Check the faucet provides the tokens correctly
2expect(await token.balanceOf(signer.address)).to.equal(1000)

Aquí verificamos el balance. No es necesario ahorrar gas en las funciones view, por lo que las ejecutamos con normalidad.

1// Give the CDI an allowance (approvals cannot be proxied)
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 la autorización para realizar transferencias.

1// Transfer tokens
2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
3const transferTx = {
4 to: cdi.address,
5 data: "0x02" + destAddr.slice(2, 42) + "0100",
6}

Crea una transacción de transferencia. El primer byte es "0x02", seguido por la dirección de destino y finalmente la cantidad (0x0100, que es 256 en decimal).

1 await (await signer.sendTransaction(transferTx)).wait()
2
3 // Check that we have 256 tokens less
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // And that our destination got them
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe
Mostrar todo

Ejemplo

Si quieres ver esos archivos en acción sin ejecutarlas por ti mismo, sigue estos enlaces:

  1. Despliegue de OrisUselessToken(opens in a new tab) a la dirección 0x950c753c0edbde44a74d3793db738a318e9c8ce8(opens in a new tab).

  2. Despliegue de CalldataInterpreter(opens in a new tab) a la dirección 0x16617fea670aefe3b9051096c0eb4aeb4b3a5f55(opens in a new tab).

  3. Llama a faucet()(opens in a new tab).

  4. Llama a OrisUselessToken.approve()(opens in a new tab). Este llamado debe ir directamente al contrato del token porque el procesamiento depende de msg.sender.

  5. Llama a transfer()(opens in a new tab).

Reduciendo el costo cuando controlas el contrato de destino

Si controlas el contrato de la dirección de destino puedes crear funciones para omitir las revisiones de msg.sender porque confían en el intérprete de calldata. Aquí puedes ver un ejemplo de cómo esto funciona en la rama de control-contract(opens in a new tab).

Si el contrato sólo ha respondido a transacciones externas, podemos arreglárnoslas teniendo sólo un contrato. Sin embargo, eso puede romper la capacidad de composición. Es mejor tener un contrato que responda a llamados normales ERC-20 y otro contrato que responda a transacciones con llamados cortos de datos.

Token.sol

En este ejemplo podemos modificar Token.sol. Esto nos permite tener un número de funciones que sólo el proxy podría llamar. Aquí están las nuevas partes:

1 // The only address allowed to specify the CalldataInterpreter address
2 address owner;
3
4 // The CalldataInterpreter address
5 address proxy = address(0);
Copiar

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. El contrato es instanciado primero porque el proxy espera la dirección del token en su constructor.

1 /**
2 * @dev Calls the ERC20 constructor.
3 */
4 constructor(
5 ) ERC20("Oris useless token-2", "OUT-2") {
6 owner = msg.sender;
7 }
Copiar

La dirección del creador (llamada owner) es almacenada aquí porque esa es la única dirección permitida para establecer el proxy.

1 /**
2 * @dev set the address for the proxy (the CalldataInterpreter).
3 * Can only be called once by the owner
4 */
5 function setProxy(address _proxy) external {
6 require(msg.sender == owner, "Can only be called by owner");
7 require(proxy == address(0), "Proxy is already set");
8
9 proxy = _proxy;
10 } // function setProxy
Mostrar todo
Copiar

El proxy tiene acceso privilegiado, porque puede omitir las revisiones de seguridad. Para asegurarnos que podemos confier en el proxy solo le permitimos al owner llamar esta función y solo una vez. Una vez que proxy tiene un valor real (diferente a cero), ese valor no puede cambiar, incluso si el propietario decide volverse pícaro o el mnemotécnico para este es revelado, entonces todavía estamos seguros.

1 /**
2 * @dev Some functions may only be called by the proxy.
3 */
4 modifier onlyProxy {
Copiar

Esta es una función modifier(opens in a new tab), esta modifica la manera en que operan otras funciones.

1 require(msg.sender == proxy);
Copiar

Primero, verificamos que nos ha llamado el proxy y nadie más. Si no, revert.

1 _;
2 }
Copiar

En caso de ser así, ejecutamos la función que modificamos.

1 /* Functions that allow the proxy to actually proxy for accounts */
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
Copiar

Esas son tres operaciones que normalmente requieren que el mensaje proviene directamente de la entidad transfiriendo tokens o aprovando una autorización. Aquí tenemos una versión proxy de estas operaciones que:

  1. Es modificada por onlyProxy(), por lo que nadie cuenta con la autorización de controlarlo.
  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 que se encuentra arriba, con la excepción de que las funciones de proxy reciben un parámetro msg.sender y no es necesaria una asignación para transfer.

1 // transfer (no need for allowance)
2 if (_func == 2) {
3 token.transferProxy(
4 msg.sender,
5 address(uint160(calldataVal(1, 20))),
6 calldataVal(21, 2)
7 );
8 }
9
10 // approve
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
Copiar

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)
Copiar

Necesitamos decirle al contrato ERC-20 en cual proxy debe confiar

1console.log("CalldataInterpreter addr:", cdi.address)
2
3// Need two signers to verify allowances
4const signers = await ethers.getSigners()
5const signer = signers[0]
6const poorSigner = signers[1]
Copiar

Para revisar approve() y transferFrom() necesitamos un segundo firmante. Lo llamaremos poorSigner porque no obtiene alguno de nuestros tókens (por supuesto, no es necesario contar con ETH).

1// Transfer 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()
Copiar

Debido a que el contrato ERC-20 confía en el proxy (cdi), no necesitamos una asignación para retransmitir transferencias.

1// approval and 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// Check the approve / transferFrom combo was done correctly
17expect(await token.balanceOf(destAddr2)).to.equal(255)
Mostrar todo
Copiar

Prueba las dos nuevas funciones. Toma en cuenta que transferFromTx requiere dos direcciones como parámetros: el dador de la asignación y el receptor.

Ejemplo

Si quieres ver esos archivos en acción sin ejecutarlas por ti mismo, sigue estos enlaces:

  1. Despliegue de OrisUselessToken-2(opens in a new tab) en la dirección 0xb47c1f550d8af70b339970c673bbdb2594011696(opens in a new tab).
  2. Despliegue de CalldataInterpreter(opens in a new tab) en la dirección0x0dccfd03e3aaba2f8c4ea4008487fd0380815892(opens in a new tab).
  3. Llamar a setProxy()(opens in a new tab).
  4. Llamar a faucet()(opens in a new tab).
  5. Llamar a transferProxy()(opens in a new tab).
  6. Llamar a approveProxy()(opens in a new tab).
  7. Llamar a transferFromProxy()(opens in a new tab). Tome en cuenta que este llamado proviene de una dirección diferente a las anteriores, poorSigner en el lugar de signer.

Conclusión

Optimism(opens in a new tab) y Arbitrum(opens in a new tab) están buscando algunas maneras de reducir el tamaño del calldata escrito a L1 y por lo tanto el costo de las transacciones. Sin embargo, como proveedores buscando por soluciones genéricas, nuestras habilidades están limitadas. Como el desarrollador dapp, tienes conocimiento específico de la aplicación, lo que te permite optimizar tu calldata mejor a como lo harías en una solución genérica. Con un poco de suerte, este artículo puede ayudarte a encontrar la solución ideal a tus necesidades.

Última edición: @chiogonzalezt(opens in a new tab), 21 de febrero de 2024

¿Le ha resultado útil este tutorial?