ABI cortas para la optimización de datos de llamada
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 requiere que optimicemos para cosas distintas que en la red principal de Ethereum. También aprenderá cómo implementar esta optimización.
Divulgación completa
Soy un empleado a tiempo completo de Optimism (opens in a new tab), por lo 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 hablar de 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 dependa de la 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 de la l2?
Los rollups optimistas tienen que conservar un registro de cada transacción histórica para que cualquiera pueda revisarlas y verificar que el estado actual sea correcto. La forma más barata de introducir datos en la red principal de Ethereum es escribirlos como datos de llamada. Esta solución fue elegida tanto por Optimism (opens in a new tab) como por Arbitrum (opens in a new tab).
Costo de las transacciones de la l2
El costo de las transacciones de la l2 se compone de dos elementos:
- Procesamiento de la l2, que suele ser extremadamente barato
- Almacenamiento de la l1, que está vinculado a los costos de gas de la Red principal
Mientras escribo esto, en Optimism el costo del gas de la l2 es de 0.001 Gwei. El costo del gas de la l1, por otro lado, es de aproximadamente 40 Gwei. Puede ver los precios actuales aquí (opens in a new tab).
Un byte de datos de llamada 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 la l2 es de 22100 de gas. Actualmente, esto equivale a 22.1 Gwei. Por lo tanto, si podemos ahorrar un solo byte cero de datos de llamada, podremos escribir unos 200 bytes en el almacenamiento y aun así salir ganando.
La 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 según la interfaz binaria de aplicación (ABI) (opens in a new tab).
Sin embargo, la ABI fue diseñada para la l1, donde un byte de datos de llamada cuesta aproximadamente lo mismo que cuatro operaciones aritméticas, no para la l2, donde un byte de datos de llamada cuesta más de mil operaciones aritméticas. Los datos de llamada se dividen de la siguiente manera:
| 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 normalmente no son cero y, por lo tanto, cuestan dieciséis de gas (opens 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 contienen cero cuestan cuatro de gas (consulte el Libro Amarillo (opens in a new tab), Apéndice G,
pág. 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, obtenemos una cantidad máxima de 1036. 25615 > 1036, por lo que quince bytes son suficientes.
Un desperdicio de 160 de gas en la 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 la l2, las cosas son diferentes. Casi todo el costo de la transacción es escribirla en la 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 aproximadamente el 6,5 % de eso.
Reducción de costos cuando no se controla el destino
Asumiendo 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 faucet permite a cualquier usuario obtener algunos tokens para usar.
Haría inútil un contrato ERC-20 de producción, pero facilita la vida cuando un ERC-20 existe solo para facilitar las pruebas.
/**
* @dev Da al llamador 1000 tokens para jugar
*/
function faucet() external {
_mint(msg.sender, 1000);
} // function faucet
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). Repasémoslo línea por línea.
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
import { OrisUselessToken } from "./Token.sol";
Necesitamos la función del token para saber cómo llamarla.
contrato CalldataInterpreter {
OrisUselessToken public immutable token;
La dirección del token para el cual somos un contrato proxy.
/**
* @dev Especifica la dirección del token
* @param tokenAddr_ dirección del contrato ERC-20
*/
constructor(
address tokenAddr_
) {
token = OrisUselessToken(tokenAddr_);
} // constructor
La dirección del token es el único parámetro que necesitamos especificar.
function calldataVal(uint startByte, uint length)
private pure returns (uint) {
Lee un valor de los datos de llamada.
uint _retVal;
require(length < 0x21,
"calldataVal length limit is 32 bytes");
require(length + startByte <= msg.data.length,
"calldataVal trying to read beyond calldatasize");
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 la l1 podría ser necesario omitir estas pruebas para ahorrar gas, pero en la l2 el gas es extremadamente barato, lo que permite cualquier control de cordura que se nos ocurra.
assembly {
_retVal := calldataload(startByte)
}
Podríamos haber copiado los datos de la llamada a fallback() (ver a continuación), pero es más fácil usar Yul (opens in a new tab), el lenguaje ensamblador 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>...).
_retVal = _retVal >> (256-length*8);
Solo los length bytes más significativos forman parte del campo, por lo que desplazamos 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, por lo que es el valor en sí mismo en lugar del valor multiplicado por 256algo.
return _retVal;
}
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) (asumiendo que haya una).
En el caso de CalldataInterpreter, cualquier llamada llega aquí porque no hay otras funciones external o public.
uint _func;
_func = calldataVal(0, 1);
Lee 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í:
- 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. - Las funciones que dependen de
msg.sender(opens in a new tab). El valor demsg.senderva a ser la dirección deCalldataInterpreter, no la del llamador.
Desafortunadamente, al observar las especificaciones del ERC-20 (opens in a new tab), esto deja solo 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ó).
// Llama a los métodos que cambian el estado del token usando
// información de los datos de llamada
// faucet
if (_func == 1) {
Una llamada a faucet(), que no tiene parámetros.
token.faucet();
token.transfer(msg.sender,
token.balanceOf(address(this)));
}
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í los necesita.
Así que transferimos todos nuestros tokens a quien nos haya llamado.
// transfer (asumiendo que tenemos una asignación para ello)
if (_func == 2) {
La transferencia de tokens requiere dos parámetros: la dirección de destino y la cantidad.
token.transferFrom(
msg.sender,
Solo permitimos a los llamadores transferir los tokens que poseen
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 una longitud de 20 bytes.
calldataVal(21, 2)
Para este contrato en particular, asumimos que el número máximo de tokens que alguien querría transferir cabe en dos bytes (menos de 65536).
);
}
En general, una transferencia requiere 35 bytes de datos de llamada:
| Sección | Longitud | Bytes |
|---|---|---|
| Selector de función | 1 | 0 |
| Dirección de destino | 32 | 1-32 |
| Cantidad | 2 | 33-34 |
} // fallback
} // contract CalldataInterpreter
test.js
Esta prueba unitaria en JavaScript (opens in a new tab) nos muestra cómo usar este mecanismo (y cómo verificar que funciona correctamente). Voy a asumir que entiende chai (opens in a new tab) y ethers (opens in a new tab) y solo explicaré las partes que se aplican específicamente al contrato.
const { expect } = require("chai");
describe("CalldataInterpreter", function () {
it("Should let us use tokens", async function () {
const Token = await ethers.getContractFactory("OrisUselessToken")
const token = await Token.deploy()
await token.deployed()
console.log("Token addr:", token.address)
const Cdi = await ethers.getContractFactory("CalldataInterpreter")
const cdi = await Cdi.deploy(token.address)
await cdi.deployed()
console.log("CalldataInterpreter addr:", cdi.address)
const signer = await ethers.getSigner()
Comenzamos implementando ambos contratos.
// Obtener tokens para jugar
const faucetTx = {
No podemos usar las funciones de alto nivel que usaríamos normalmente (como token.faucet()) para crear transacciones, porque no seguimos la ABI.
En su lugar, tenemos que construir la transacción nosotros mismos y luego enviarla.
to: cdi.address,
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 datos de llamada.data, los datos de llamada a enviar. En el caso de una llamada al faucet, los datos son un solo byte,0x01.
}
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 esté firmada.
// Comprobar que el faucet proporciona los tokens correctamente
expect(await token.balanceOf(signer.address)).to.equal(1000)
Aquí verificamos el saldo.
No hay necesidad de ahorrar gas en las funciones view, así que simplemente las ejecutamos normalmente.
// Dar al CDI una asignación (las aprobaciones no pueden ser delegadas por proxy)
const approveTX = await token.approve(cdi.address, 10000)
await approveTX.wait()
expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)
Otorgue al intérprete de datos de llamada una asignación para poder realizar transferencias.
// Transferir tokens
const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
const transferTx = {
to: cdi.address,
data: "0x02" + destAddr.slice(2, 42) + "0100",
}
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).
await (await signer.sendTransaction(transferTx)).wait()
// Comprobar que tenemos 256 tokens menos
expect (await token.balanceOf(signer.address)).to.equal(1000-256)
// Y que nuestro destino los recibió
expect (await token.balanceOf(destAddr)).to.equal(256)
}) // it
}) // describe
Reducción del costo cuando sí se controla el contrato de destino
Si tiene control sobre el contrato de destino, puede crear funciones que omitan las comprobaciones de msg.sender porque confían en el intérprete de datos de llamada.
Puede ver un ejemplo de cómo funciona esto aquí, en la rama control-contract (opens in a new tab).
Si el contrato respondiera solo a transacciones externas, podríamos arreglárnoslas con tener un solo contrato. Sin embargo, eso rompería la composabilidad. Es mucho mejor tener un contrato que responda a las llamadas normales de ERC-20 y otro contrato que responda a las transacciones con datos de llamada cortos.
Token.sol
En este ejemplo podemos modificar Token.sol.
Esto nos permite tener una serie de funciones que solo el contrato proxy puede llamar.
Aquí están las partes nuevas:
// La única dirección permitida para especificar la dirección del CalldataInterpreter
address owner;
// La dirección del CalldataInterpreter
address proxy = address(0);
El contrato ERC-20 necesita conocer la identidad del contrato 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 contrato proxy espera la dirección del token en su constructor.
/**
* @dev Llama al constructor de ERC-20.
*/
constructor(
) ERC20("Oris useless token-2", "OUT-2") {
owner = msg.sender;
}
La dirección del creador (llamada owner) se almacena aquí porque esa es la única dirección autorizada para establecer el contrato proxy.
/**
* @dev establece la dirección para el proxy (el CalldataInterpreter).
* Solo puede ser llamado una vez por el propietario
*/
function setProxy(address _proxy) external {
require(msg.sender == owner, "Can only be called by owner");
require(proxy == address(0), "Proxy is already set");
proxy = _proxy;
} // function setProxy
El contrato proxy tiene acceso privilegiado, porque puede omitir las comprobaciones de seguridad.
Para asegurarnos de que podemos confiar en el contrato proxy, solo permitimos que owner llame a esta función, y solo una vez.
Una vez que proxy tiene un valor real (no cero), ese valor no puede cambiar, por lo que incluso si el propietario decide volverse malicioso, o se revela su frase mnemotécnica, seguimos estando a salvo.
/**
* @dev Algunas funciones solo pueden ser llamadas por el proxy.
*/
modifier onlyProxy {
Esta es una función modifier (opens in a new tab), modifica la forma en que funcionan otras funciones.
require(msg.sender == proxy);
Primero, verifique que fuimos llamados por el contrato proxy y por nadie más.
Si no es así, revert.
_;
}
Si es así, ejecute la función que modificamos.
/* Funciones que permiten al proxy actuar realmente como proxy para las cuentas */
function transferProxy(address from, address to, uint256 amount)
public virtual onlyProxy() returns (bool)
{
_transfer(from, to, amount);
return true;
}
function approveProxy(address from, address spender, uint256 amount)
public virtual onlyProxy() returns (bool)
{
_approve(from, spender, amount);
return true;
}
function transferFromProxy(
address spender,
address from,
address to,
uint256 amount
) public virtual onlyProxy() returns (bool)
{
_spendAllowance(from, spender, amount);
_transfer(from, to, amount);
return true;
}
Estas son tres operaciones que normalmente requieren que el mensaje provenga directamente de la entidad que transfiere tokens o aprueba una asignación. Aquí tenemos una versión proxy de estas operaciones que:
- Está modificada por
onlyProxy()para que a nadie más se le permita controlarlas. - Obtiene la dirección que normalmente sería
msg.sendercomo un parámetro adicional.
CalldataInterpreter.sol
El intérprete de datos de llamada es casi idéntico al anterior, excepto que las funciones proxy reciben un parámetro msg.sender y no hay necesidad de una asignación para transfer.
// transfer (sin necesidad de asignación)
if (_func == 2) {
token.transferProxy(
msg.sender,
address(uint160(calldataVal(1, 20))),
calldataVal(21, 2)
);
}
// approve
if (_func == 3) {
token.approveProxy(
msg.sender,
address(uint160(calldataVal(1, 20))),
calldataVal(21, 2)
);
}
// transferFrom
if (_func == 4) {
token.transferFromProxy(
msg.sender,
address(uint160(calldataVal( 1, 20))),
address(uint160(calldataVal(21, 20))),
calldataVal(41, 2)
);
}
Test.js
Hay algunos cambios entre el código de prueba anterior y este.
const Cdi = await ethers.getContractFactory("CalldataInterpreter")
const cdi = await Cdi.deploy(token.address)
await cdi.deployed()
await token.setProxy(cdi.address)
Necesitamos decirle al contrato ERC-20 en qué contrato proxy confiar
console.log("CalldataInterpreter addr:", cdi.address)
// Se necesitan dos firmantes para verificar las asignaciones
const signers = await ethers.getSigners()
const signer = signers[0]
const poorSigner = signers[1]
Para comprobar approve() y transferFrom() necesitamos un segundo firmante.
Lo llamamos poorSigner porque no obtiene ninguno de nuestros tokens (por supuesto, sí necesita tener ETH).
// Transferir tokens
const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
const transferTx = {
to: cdi.address,
data: "0x02" + destAddr.slice(2, 42) + "0100",
}
await (await signer.sendTransaction(transferTx)).wait()
Debido a que el contrato ERC-20 confía en el contrato proxy (cdi), no necesitamos una asignación para retransmitir transferencias.
// aprobación y transferFrom
const approveTx = {
to: cdi.address,
data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",
}
await (await signer.sendTransaction(approveTx)).wait()
const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"
const transferFromTx = {
to: cdi.address,
data: "0x04" + signer.address.slice(2, 42) + destAddr2.slice(2, 42) + "00FF",
}
await (await poorSigner.sendTransaction(transferFromTx)).wait()
// Comprobar que la combinación approve / transferFrom se realizó correctamente
expect(await token.balanceOf(destAddr2)).to.equal(255)
Pruebe las dos funciones nuevas.
Tenga en cuenta que transferFromTx requiere dos parámetros de dirección: el otorgante de la asignación y el receptor.
Conclusión
Tanto Optimism (opens in a new tab) como Arbitrum (opens in a new tab) están buscando formas de reducir el tamaño de los datos de llamada escritos en la l1 y, por lo tanto, el costo de las transacciones. Sin embargo, como proveedores de infraestructura que buscan soluciones genéricas, nuestras capacidades son limitadas. Como desarrollador de aplicaciones descentralizadas (dapp), usted tiene conocimientos específicos de la aplicación, lo que le permite optimizar sus datos de llamada mucho mejor de lo que podríamos hacerlo nosotros en una solución genérica. Esperamos que este artículo le ayude a encontrar la solución ideal para sus necesidades.