Saltar al contenido
0%

¿Cómo funcionan las llamadas a contratos en Yul?

Mark Jonathas headshot

Escrito por Mark Jonathas

Publicado el 1 de agosto de 20237 min de lectura

Yul es un lenguaje de programación intermedio que se puede usar para escribir una forma de lenguaje ensamblador dentro de smart contracts. Ahora que entiendes la sintaxis, el storage y cómo funciona la memoria en Yul, es momento de llamar contratos.

¿Cómo funcionan las llamadas a contratos en yul?

En la sección final de esta serie veremos cómo funcionan las llamadas a contratos en Yul. Antes de ver algunos ejemplos, necesitamos aprender algunas operaciones más de Yul. Veamos.‍

Instruction
Explanation

gas()

La cantidad de gas que todavía está disponible para la ejecución

gasPrice()

El precio del gas de la transacción

address()

La dirección del contrato actual

balance(a)

El balance de ether, en wei, de la dirección a

selfbalance()

Lo mismo que llamar a balance(address()), pero un poco más barato

caller()

La dirección que llamó al contrato (msg.sender)

origin()

La dirección que originó la transacción (tx.origin)

callvalue()

La cantidad de ether, en wei, que se envió en la llamada al contrato (msg.value)

calldataload(p)

El calldata comenzando en la posición p (solo 32 bytes de datos)

calldatacopy(t, f, s)

El calldata se copia a la ubicación de memoria t. Comenzando desde la posición f en calldata. Copia s bytes de datos.

extcodesize(a)

El tamaño del código en la dirección a

returndatasize()

El tamaño del último returndata

returndatacopy(t, f, s)

Copia s bytes desde el return data en la posición f a la memoria en la posición t

timestamp()

El timestamp actual del bloque en segundos

number()

El número de bloque actual

call(g, a, v, in, insize, out, outsize)

Llama a un contrato en la dirección a, con gas g, pasando v wei como msg.value, pasando tx.data desde la ubicación de memoria in - insize, y almacenando el return data en la ubicación de memoria out - outsize. Retorna 1 si la llamada fue exitosa, de lo contrario retorna 0.

delegatecall(g, a, in, insize, out, outsize)

Similar a call(). La principal diferencia es que delegatecall() actualiza las variables de estado en el contrato que hace la llamada. A menudo se usa para contratos proxy. Es importante notar que el storage layout debe ser idéntico al del contrato al que llamas, para evitar sobrescribir variables de storage no deseadas. Nota que falta el parámetro v. No puedes enviar ether con delegatecall().

staticcall(g, a, in, insize, out, outsize)

Similar a call(). Excepto que no se puede usar para llamar a contratos que cambian el estado de la blockchain (es decir, funciones marcadas como pure y view). Nota que falta el parámetro v. No puedes enviar ether con staticcall().

Bien, ahora veamos algunos contratos nuevos para estos ejemplos. Primero, veamos el contrato que vamos a llamar.

Este contrato tiene dos variables de storage, var1 y var2, que se almacenan en los storage slots 1 y 2 respectivamente.

La función a() requiere que el usuario envíe al menos 1 ether al contrato, de lo contrario revierte.

Luego, la función a() actualiza var1 y var2 y las retorna.

La función b() simplemente lee var1 y var2 y las retorna.

Antes de pasar a nuestro contrato que llama al contrato CallMe, necesitamos tomarnos un momento para entender los function selectors.

Veamos el siguiente calldata para una transacción 0x773d45e000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002.

Los primeros 4 bytes del calldata son lo que se conoce como el function selector (0x773d45e0). Así es como el EVM sabe qué función quieres llamar. El function selector se obtiene tomando los primeros 4 bytes del hash de un string con la firma de la función.

Entonces, la firma de la función a() sería a(uint256,uint256).

Tomando el hash de este string obtenemos: 0x773d45e097aa76a22159880d254a5f1db8365bc2d0f0987a82bda7dfd3b9c8aa.

Al ver los primeros 4 bytes, vemos que es igual a 0x773d45e0.

Nota la ausencia de espacios en la firma. Esto es importante porque agregar espacios nos daría un hash completamente distinto. No tienes que preocuparte por obtener los selectors para nuestros ejemplos de código, yo los proporcionaré.

Empecemos viendo el storage layout.

Nota cómo var1 y var2 tienen el mismo layout que el contrato CallMe. Recordarás que mencioné que el layout debe ser el mismo que el de nuestro otro contrato para que delegatecall() funcione correctamente.

Cumplimos con esos requisitos y podemos tener otras variables (selectorA y selectorB) siempre que nuestras nuevas variables se agreguen al final. Esto evita colisiones de storage.

Ahora estamos listos para hacer nuestra primera llamada a un contrato.

Cómo usar staticcall() en yul

Empecemos con algo simple, staticcall(). Aquí está nuestra función:

Esto es lo que está pasando:

  • Obtenemos el function selector de b() desde storage cargando el slot 2 (ambos selectors están empaquetados en un mismo slot)
  • Hacemos un shift a la derecha de 4 bytes (32 bits) para aislar selectorB
  • Almacenamos el function selector en el scratch space de la memoria
  • Hacemos nuestra static call
  • Pasamos gas() (también puedes especificar la cantidad de gas)
  • Pasamos el parámetro _callMe como la dirección del contrato
  • Verificamos si la llamada a la función fue exitosa; de lo contrario, retornamos sin datos.
  • Retornamos nuestros datos desde memoria y vemos los valores 1 y 2

Nota: 0x1c y 0x20 indican que queremos pasar los últimos 4 bytes de lo que almacenamos en el scratch space. Los últimos dos parámetros de staticcall() especifican que queremos almacenar el return data en las ubicaciones de memoria 0x80 - 0xc0.

Cómo usar call() en yul

Ahora veamos call(). Vamos a llamar a la función a() de CallMe. ¡Recuerda enviar al menos 1 ether al contrato! Voy a pasar 3 y 4 como _var1 y _var2 para este ejemplo.

Aquí está el código:

Bien, similar a nuestro ejemplo anterior, tenemos que cargar el slot2. Esta vez, sin embargo, vamos a hacer un mask de selectorB para aislar selectorA.

Ahora almacenaremos el selector en 0x80.

Como necesitamos parámetros del calldata, vamos a usar calldatacopy(). Le estamos indicando a calldatacopy() que:

  • almacene nuestro calldata en la ubicación de memoria 0xa0
  • se salte los primeros 36 bytes
  • almacene el tamaño del calldata menos 36 bytes

Nota: los primeros 4 bytes que saltamos son el function selector de callA() y los siguientes 32 bytes son la dirección de callMe.

Ahora estamos listos para hacer nuestra llamada al contrato.

Como la última vez, pasamos gas() y _callMe. Sin embargo, esta vez pasamos nuestro calldata desde 0x9c (los últimos 4 bytes de la serie de memoria en 0x80) hasta 0xe0, y almacenamos nuestros datos en la ubicación de memoria 0x100 - 0x120.

De nuevo, verificamos si la llamada fue exitosa y retornamos nuestro output. Si revisamos el contrato CallMe, vemos que los valores se actualizaron correctamente a 3 y 4.

Para aclarar aún más lo que está pasando, aquí está el layout de memoria justo antes de retornar:

Memory Location
Value Stored

0x00

Scratch Space (Vacío)

0x20

Scratch Space (Vacío)

0x40

0x80 (Nunca actualizamos el Free Memory Pointer, ya que todas las operaciones se hacen manualmente en Yul.)

0x60

Vacío

0x80

0x0000000000000000000000000000000000000000000000000000000000773d45e0 (Function Selector)

0xa0

0x0000000000000000000000000000000000000000000000000000000000000003 (_var1)

0xc0

0x0000000000000000000000000000000000000000000000000000000000000004 (_var2)

0xe0

Vacío

0x100

0x0000000000000000000000000000000000000000000000000000000000000003 (Primer valor de retorno)

0x120

0x0000000000000000000000000000000000000000000000000000000000000004 (Segundo valor de retorno)

0x140

Free Memory Pointer (Vacío)

Cómo usar delegate call en yul

En nuestra última sección veremos delegatecall(). El código se verá casi idéntico a call(), con un solo cambio.

El único cambio que hicimos fue cambiar call() por delegatecall() y eliminar callvalue().

No necesitamos un callvalue(), porque delegatecall ejecuta el código de CallMe dentro de su propio estado. Por lo tanto, el statement require() en a() está verificando si se envió ether a nuestro contrato Caller. Si revisamos var1 y var2 en CallMe, vemos que no hubo cambios. Sin embargo, var1 y var2 en nuestro contrato Caller se actualizaron correctamente.

¡Esto concluye nuestra sección sobre llamadas a contratos! A continuación, aprende cómo funcionan Memory, Storage y Smart Contract Calls en Yul.

Background gradient

Construye magia blockchain

Alchemy combina los productos y herramientas de desarrollo Web3 más potentes con recursos, comunidad y un soporte legendario.