Ir al contenido principal

ABI cortas para la optimización de calldata

capa 2
Intermedio
Ori Pomerantz
1 de abril de 2022
16 minuto leído minute read

Introducción

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

Divulgación completa

Soy 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

Al analizar 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 base en L1 para la seguridad, pero que realice 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 Optimistic rollups tienen que preservar un registro de cada transacción histórica para que cualquiera pueda verlas y verificar que el estado actual sea correcto. La forma más barata de obtener datos en la Red principal de Ethereum es escribirlos como datos de llamada, o calldata. Esta solución fue elegida tanto por Optimism(opens in a new tab) como por Arbitrum(opens in a new tab).

Costo de 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 del gas de la Red principal.

Mientras escribo esto, en Optimism el costo del gas en L2 es 0,001 Gwei. Por otra parte, el costo del gas en L1 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 para almacenar en L2 es de 22.100 gas. Actualmente, esto es 22.1 gwei. Así que, si podemos ahorrar un único byte cero de datos de llamada, o calldata, podremos escribir unos 200 bytes en el almacenamiento y aun así saldremos adelante.

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 se escriben en Solidity e interpretan su campo de datos por 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 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í hay 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
Cantidad3236-67176415240
Total68160576

Explicación:

  • Selector de funciones: El contrato tiene menos de 256 funciones, así que podamos distinguirlas con un solo byte. Estos bytes suelen ser distintos de cero y, por lo tanto, cuestan 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. Los bytes que contienen cero cuestan cuatro de gas (ver el Yellow Paper(opens in a new tab), Apéndice G, pag. 27, el valor de Gtxdatazero).
  • Cantidad: 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, de modo que quince bytes serán suficientes.

Un gasto de 160 en gas en L1 normalmente es insignificante. Una transacción cuesta al menos 21.000 gas(opens in a new tab), por lo que un 0,8% adicional no es significativo. 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 gastando alrededor del 6,5 % de eso.

Reducir costos cuando no se controla el destino

Suponiendo que no tiene control sobre el contrato de destino, aún puede usar una solución similar a esta(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 de faucet permite a cualquier usuario obtener tokens para usar. 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 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 que la función de token sepa cómo llamar.

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 un valor de los datos de llamada.

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 gas, pero en L2 el gas es extremadamente barato, lo que permite realizar cualquier control de seguridad, o sanity check, 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 de ensamblado de la 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 <opcode name>(<first stack value, if any>,<second stack value, if any>...)..

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

Solo los bytes de length más significativos forman parte del campo, por lo que hacemos un desplazamiento a la derecha(opens in a new tab) para deshacernos de los otros valores. Esto tiene la ventaja adicional de mover el valor a la derecha del campo, de modo que sea 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 a la función fallback()(opens in a new tab) (asumiendo que haya una). En el caso de CalldataInterpreter, cualquier llamada llega aquí porque no hay ninguna otra función external o public.

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

Lea el primer byte de los datos de llamada, que nos indica la función. Hay dos razones por las que una función no estaría disponible aquí:

  1. Las funciones 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 usan msg.sender(opens in a new tab). El valor de msg.sender será la dirección de CalldataInterpreter, no la persona que llama.

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

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. La EOA (cuenta de propiedad externa) o el contrato que nos llama, en cambio, sí. 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 destino empieza en el byte n.º 1 (el byte 0 es la función). Como una dirección, tiene una longitud de 20 bytes.

1 calldataVal(21, 2)
Copiar

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

1 );
2 }
Copiar

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

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

test.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 entiende chai(opens in a new tab) y ethers(opens in a new tab), y solo voy a explicar específicamente las partes que corresponden específicamente al 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 usaríamos (como token.faucet()) para crear transacciones, porque no seguimos la ABI. En cambio, tenemos que crear 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 a enviar. En caso de una llamada de grifo (faucet), la información tiene 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 saldo. 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}

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 // 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 quiere ver esos archivos en acción sin ejecutarlos usted mismo, siga estos enlaces:

  1. Implementación de OrisUselessToken(opens in a new tab) a la dirección 0x950c753c0edbde44a74d3793db738a318e9c8ce8(opens in a new tab).
  2. Implementación de CalldataInterpreter(opens in a new tab) a la dirección 0x16617fea670aefe3b9051096c0eb4aeb4b3a5f55(opens in a new tab).
  3. Llamada al faucet()(opens in a new tab).
  4. Llamada 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. Llamada a transfer()(opens in a new tab).

Reducir el costo cuando controla el 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 de la llamada de datos. Aquí puede ver un ejemplo de cómo funciona esto en la rama de control-contract(opens 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 capacidad de composición. 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 // 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 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 hacerse el pícaro o se revela el mnemotécnico de esto, aún tendríamos seguridad.

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) que modifica la manera en que operan otras funciones.

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

Primero, verificamos que nos ha llamado el proxy y ningún otro. 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 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(), 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 los datos de llamada, 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 cuál 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 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// 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 transmitir 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

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.

Ejemplo

Si quiere ver esos archivos en acción sin ejecutarlos usted mismo, siga estos enlaces:

  1. Implementación de OrisUselessToken-2(opens in a new tab) en la dirección 0xb47c1f550d8af70b339970c673bbdb2594011696(opens in a new tab).
  2. Implementación de CalldataInterpreter(opens in a new tab) en la dirección 0x0dccfd03e3aaba2f8c4ea4008487fd0380815892(opens in a new tab).
  3. Llamada a setProxy()(opens in a new tab).
  4. Llamada a faucet()(opens in a new tab).
  5. Llamada a transferProxy()(opens in a new tab).
  6. Llamada a approveProxy()(opens in a new tab).
  7. Llamada a transferFromProxy()(opens in a new tab). Tenga en cuenta que esta llamada proviene de una dirección diferente a las anteriores, poorSigner en lugar de signer.

Conclusión

Optimism(opens in a new tab) y Arbitrum(opens in a new tab) están buscando maneras de reducir el tamaño de los datos de llamada escritos 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.

Última edición: @nhsz(opens in a new tab), 1 de mayo de 2024

¿Le ha resultado útil este tutorial?