ABI cortas para la optimización de calldata
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:
- Procesamiento en L2, que generalmente es extremadamente barato.
- 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ón | Longitud | Bytes | Bytes consumidos | Gas consumido | Bytes necesarios | Gas necesario |
---|---|---|---|---|---|---|
Selector de funciones | 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 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
G
txdatazero
). - 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 with3 */4 function faucet() external {5 _mint(msg.sender, 1000);6 } // function faucetCopiar
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: Unlicense2pragma solidity ^0.8.0;345import { OrisUselessToken } from "./Token.sol";Copiar
Necesitamos que la función de token sepa cómo llamar.
1contract CalldataInterpreter {23 OrisUselessToken public immutable token;Copiar
La dirección del token del que somos proxy.
12 /**3 * @dev Specify the token address4 * @param tokenAddr_ ERC-20 contract address5 */6 constructor(7 address tokenAddr_8 ) {9 token = OrisUselessToken(tokenAddr_);10 } // constructorMostrar todoCopiar
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;23 require(length < 0x21,4 "calldataVal length limit is 32 bytes");56 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>...)
..
12 _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.
12 return _retVal;3 }456 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;23 _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í:
- Las funciones
pure
oview
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. - Funciones que usan
msg.sender
(opens in a new tab). El valor demsg.sender
será la dirección deCalldataInterpreter
, 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).
12 // Call the state changing methods of token using3 // information from the calldata45 // faucet6 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ón | Longitud | Bytes |
---|---|---|
Selector de funciones | 1 | 0 |
Dirección de destino | 32 | 1-32 |
Cantidad | 2 | 33-34 |
1 } // fallback23} // contract CalldataInterpreterCopiar
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");23describe("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)910 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)1415 const signer = await ethers.getSigner()Mostrar todoCopiar
Empezamos implementando ambos contratos.
1 // Get tokens to play with2 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:
to
, la dirección de destino. Este es el contrato intérprete de calldata.data
, el calldata a enviar. En caso de una llamada de grifo (faucet), la información tiene un único byte,0x01
.
12 }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 correctly2expect(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 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 // Check that we have 256 tokens less4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)56 // And that our destination got them7 expect (await token.balanceOf(destAddr)).to.equal(256)8 }) // it9}) // describeMostrar todo
Ejemplo
Si quiere ver esos archivos en acción sin ejecutarlos usted mismo, siga estos enlaces:
- Implementación de
OrisUselessToken
(opens in a new tab) a la dirección0x950c753c0edbde44a74d3793db738a318e9c8ce8
(opens in a new tab). - Implementación de
CalldataInterpreter
(opens in a new tab) a la dirección0x16617fea670aefe3b9051096c0eb4aeb4b3a5f55
(opens in a new tab). - Llamada al
faucet()
(opens in a new tab). - Llamada a
OrisUselessToken.approve()
(opens in a new tab). Este llamado debe ir directamente al contrato del token porque el procesamiento depende demsg.sender
. - 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 address2 address owner;34 // The CalldataInterpreter address5 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 owner4 */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");89 proxy = _proxy;10 } // function setProxyMostrar todoCopiar
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 */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 todoCopiar
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:
- Es modificada por
onlyProxy()
, por lo que nadie cuenta con la autorización de controlarlo. - 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 }910 // approve11 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 todoCopiar
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)23// Need two signers to verify allowances4const 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 tokens2const 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 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// Check the approve / transferFrom combo was done correctly17expect(await token.balanceOf(destAddr2)).to.equal(255)Mostrar todoCopiar
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:
- Implementación de
OrisUselessToken-2
(opens in a new tab) en la dirección0xb47c1f550d8af70b339970c673bbdb2594011696
(opens in a new tab). - Implementación de
CalldataInterpreter
(opens in a new tab) en la dirección0x0dccfd03e3aaba2f8c4ea4008487fd0380815892
(opens in a new tab). - Llamada a
setProxy()
(opens in a new tab). - Llamada a
faucet()
(opens in a new tab). - Llamada a
transferProxy()
(opens in a new tab). - Llamada a
approveProxy()
(opens in a new tab). - 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 designer
.
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: @Shiva-Sai-ssb(opens in a new tab), 30 de junio de 2024