Logo NotOnly.Owner

Qué pasa cuando enviás 1 DAI


cover del artículo

Tenés 1 DAI.

Al usar la interfaz de una billetera de criptomonedas (como Metamask), hacés clic sobre los botones y completás los campos que hagan falta para decir que estás enviándole 1 DAI a la dirección 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 (ese es vitalik.eth).

Y apretás enviar.

Después de un tiempo, la billetera te informa que se confirmó la transacción. De repente, Vitalik ahora es 1 DAI más rico. ¿Qué carajo acaba de pasar?

Rebobinemos. Y volvamos a verlo en cámara lenta.

¿Preparada? ¿Preparado?


Índice

  1. Construyendo la transacción
  2. Recepción
  3. Propagación
  4. Preparación del trabajo e inclusión de la transacción
  5. Ejecución
  6. Sellado del bloque
  7. Transmisión del bloque
  8. Verificación del bloque
  9. Recuperando la transacción
  10. Palabras finales

Construyendo la transacción

Las billeteras son piezas de software que facilitan el envío de transacciones hacia la red de Ethereum.

Una transacción no es más que una forma de informarle a la red de Ethereum que, como usuario, querés ejecutar una acción. En este caso, enviarle 1 DAI a Vitalik. Y una billetera (como Metamask, por ejemplo) ayuda a crear dichas transacciones de una forma relativamente fácil, aún para los usuarios principiantes.

Empecemos por analizar la transacción que crearía una billetera. Se puede representar como un objeto con campos y sus valores correspondientes.

La nuestra empieza a verse así:

{
    "to": "0x6b175474e89094c44da98b954eedeac495271d0f",
    // [...]
}

Donde el campo to (para) designa la dirección de destino. En este caso, 0x6b175474e89094c44da98b954eedeac495271d0f es la dirección del contrato inteligente de DAI.

Pará, ¿qué?

¿No le estábamos enviando 1 DAI a Vitalik? ¿Acaso to no debería ser la dirección de Vitalik?

Bueno, no.

Para enviar DAI, uno tiene que construir una transacción que ejecute una pieza de código almacenada en la cadena de bloques (o blockchain, una forma más marketinera de referirse a la base de datos de Ethereum), que va a actualizar los balances registrados de DAI. Es decir, tanto la lógica como el almacenamiento relacionado para ejecutar dicha actualización se mantienen en un programa informático público e inmutable, que está almacenado en la base de datos. Este es el contrato inteligente de DAI.

Por ende, hay que crear una transacción que le diga al contrato «che, amigo, actualizá tus balances internos. Empezá por sacar 1 DAI de mi balance y después súmale 1 DAI al balance de Vitalik». En la jerga de Ethereum, la frase «che, amigo» se traduce como escribir la dirección de DAI en el campo to de la transacción.

El campo to no es suficiente. A partir de la información proporcionada en la interfaz de usuario (UI, por sus siglas en inglés) de tu billetera favorita, ésta completa muchos otros campos para crear una transacción con el formato correcto.

    {
        "to": "0x6b175474e89094c44da98b954eedeac495271d0f",
        "amount": 0,
        "chainId": 31337,
        "nonce": 0,
        // [...]
    }

La billetera completa el campo amount (cantidad) con un 0. Así que le estás enviando 1 DAI a Vitalik, sin usar la dirección de Vitalik ni poner 1 en el campo amount. Así de dura es la vida (y sólo estamos entrando en calor). El campo amount, en realidad, se incluye en una transacción para especificar cuánto ETH (la moneda nativa de Ethereum) estás enviando junto con tu transacción. Ya que no querés enviar ETH en este momento, entonces la billetera deja ese campo en 0.

En cuanto a chainId (identificador de cadena), este es un campo que especifica la cadena en donde se va a ejecutar la transacción. En el caso de la red principal de Ethereum (usualmente conocida como mainnet), es 1. Sin embargo, ya que voy a estar llevando a cabo este experimento en una copia local de la red principal, voy a usar otro chain ID: 31337. Otras cadenas tienen otros identificadores.

¿Y qué pasa con el campo nonce (número único)? Ese es un número que se debería incrementar cada vez que enviás una transacción a la red. Actúa como un mecanismo de defensa para evitar problemas producidos por ataques de repetición. Las billeteras, por lo general, establecen el nonce por vos. Para eso, le consultan a la red cuál es el último nonce que usó tu cuenta y, luego lo escriben en la transacción. En el ejemplo de arriba, lo puse en 0, aunque, en realidad, en última instancia el número va a depender de la cantidad de transacciones que tu cuenta haya ejecutado.

Recién dije que las billetera «le consultan a la red». Lo que quiero decir es que una billetera ejecuta una llamada de sólo lectura a un nodo de Ethereum, y el nodo responde con los datos solicitados. Existen varias maneras de leer datos de un nodo de Ethereum, dependiendo de la ubicación del nodo y del tipo de interfaces de aplicación (APIs, por sus siglas en inglés) que exponen.

Vamos a imaginar que la billetera tiene acceso de red directo a un nodo de Ethereum. Aunque vale la pena aclarar que suele ser más frecuente que las billeteras interactúen con proveedores externos (como Infura, Alchemy, QuickNode y muchos otros).

En cualquier caso, las solicitudes para interactuar con el nodo siguen un protocolo especial a la hora de ejecutar llamadas remotas. Dicho protocolo se llama JSON-RPC.

Una solicitud HTTP de una billetera que está tratando de recuperar el nonce de una cuenta va a ser similar a:

POST / HTTP/1.1
connection: keep-alive
Content-Type: application/json
content-length: 124

{
    "jsonrpc":"2.0",
    "method":"eth_getTransactionCount",
    "params":["0x6fC27A75d76d8563840691DDE7a947d7f3F179ba","latest"],
    "id":6
}
---
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 42

{"jsonrpc":"2.0","id":6,"result":"0x0"}

Donde 0x6fC27A75d76d8563840691DDE7a947d7f3F179ba sería la cuenta del emisor. En el cuerpo de la respuesta, donde dice "result", podés ver que su nonce es 0.

Las billeteras recuperan datos mediante el uso de solicitudes de red (en este caso, a través de solicitudes HTTP) para dar con los endpoints JSON-RPC expuestos por los nodos. Arriba incluí sólo uno, pero, en la práctica, una billetera puede consultar cualquier dato que necesite para crear una transacción. No te sorprendas si, en la vida real, ves más solicitudes de red en busca de otras cosas. Por ejemplo, el siguiente es un fragmento del tráfico de Metamask que alcanza a un nodo de prueba local en un par de minutos.

Captura de Wireshark de tráfico de Metamask en red local

El campo de datos de la transacción

DAI es un contrato inteligente. Su lógica principal se implementa en la dirección 0x6b175474e89094c44da98b954eedeac495271d0f, alojado en la red principal de Ethereum.

Más concretamente, DAI es un token fungible que cumple con el estándar ERC20. Un tipo de contrato bastante particular. Esto significa que, al menos, DAI implementa la interfaz detallada en la especificación del estándar ERC20. En términos de la (un tanto gastada) jerga del web2, DAI es un servicio web inmutable de código abierto que se ejecuta en Ethereum. Dado que sigue las especificaciones del estándar ERC20, es posible conocer de antemano (sin tener que mirar el código fuente) y con exactitud los endpoints expuestos para interactuar con el contrato.

Breve nota al margen: no todos los tokens ERC20 son iguales. Recordemos que seguir al pie de la letra una cierta interfaz (lo cual facilita las interacciones e integraciones) no garantiza el comportamiento interno de un contrato. De todos modos, para este ejercicio podemos asumir con seguridad que DAI es un token ERC20 bastante estándar en su comportamiento.

Existen un montón de funciones en el contrato inteligente de DAI (el código fuente está disponible aquí), muchas de ellas tomadas directamente de las especificaciones del estándar ERC20. Visto que estamos acá para transferir 1 DAI, la función externa transfer es la que más nos interesa.

contract Dai is LibNote {
    ...
    function transfer(address dst, uint wad) external returns (bool) {
        ...
    }
}

Esta función permite que cualquier usuario que tenga tokens DAI pueda transferir algunos a otra cuenta de Ethereum. Su firma es transfer(address,uint256), donde el primer parámetro es la dirección de la cuenta receptora, y el segundo, un número entero sin signo, que representa la cantidad de tokens a transferir.

Por ahora no nos enfoquemos en las especificidades del comportamiento de la función. Alcanza con creerme que cuando la función se ejecuta felizmente, se reduce el balance del emisor y se incrementa el del receptor en la cantidad indicada.

Esto es importante porque al crear una transacción que interactúe con un contrato inteligente, uno debe conocer qué función del contrato se debe ejecutar y con qué parámetros. Es como si en web2 quisieras enviarle una solicitud POST a una API de web. Es muy probable que necesites especificar la URL exacta junto con sus parámetros en la solicitud. Esto es lo mismo. Queremos transferir 1 DAI, así que hay que saber cómo especificar en una transacción que se debe ejecutar la función transfer en el contrato inteligente de DAI.

Por suerte, esto es SUPER sencillo e intuitivo.

Era una broma. No lo es. Para nada.

Esto es lo que tenés que incluir en tu transacción para enviarle 1 DAI a Vitalik (recordá, la dirección es 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045):

{
    // [...]
    "data": "0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000"
}

Siii ya se. Ya se. Ni me lo digas. Horrible.

Dejame explicar.

Con el objetivo de facilitar integraciones y estandarizar la forma en la que se interactúa con los contratos inteligentes, el ecosistema de Ethereum consensuó (más o menos) en adoptar lo que se conoce como «Especificación de la ABI del Contrato» (ABI son las siglas en inglés para Interfaz Binaria de Aplicación). En casos de uso habituales, e insisto, EN CASOS DE USO HABITUALES, para ejecutar una función del contrato inteligente primero tenés que codificar su llamada siguiendo la especificación ABI del contrato. Otros casos de uso más avanzados pueden no cumplir con esta especificación. Pero por nuestra salud mental no vamos a meternos en ese laberinto hoy. Basta con decir que los contratos inteligentes programados en Solidity como DAI, generalmente siguen la especificación ABI mencionada.

Entonces, esa cosa fea que viste arriba son los bytes resultantes de la codificación ABI de una llamada para transferir 1 DAI a la dirección 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045, ejecutando la función transfer(address,uint256) del contrato inteligente DAI.

Existen muchas herramientas para hacer codificación ABI. De alguna manera u otra, la mayoría de las billeteras la implementan para interactuar con los contratos. En este ejemplo, se puede verificar que la secuencia de bytes de arriba es correcto al usar la utilidad de línea de comandos llamada cast.

Así la uso para codificar la llamada a transfer con los argumentos específicos:

$ cast calldata "transfer(address,uint256)" 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 1000000000000000000

0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000

¿Qué pasa? ¿Te está molestando algo?

Aaah, mala mía. Claro. Ese 1000000000000000000. No te voy a mentir. Me encantaría tener un argumento más sólido para vos. El tema es que las cantidades de muchos tokens ERC20 se representan con 18 decimales. DAI por ejemplo. Pero sólo podemos usar números enteros sin signo. Así que 1 DAI, en realidad, se almacena como 1 * 10\^18, que es igual a 1000000000000000000. Es lo que hay.

Tenemos una bella secuencia de bytes codificada por ABI que se va a incluir en el campo data (datos) de la transacción. Por ahora se ve así:

{
    "to": "0x6b175474e89094c44da98b954eedeac495271d0f",
    "amount": 0,
    "chainId": 31337,
    "nonce": 0,
    "data": "0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000"
}

Hablaremos de los contenidos del campo data una vez que lleguemos a la verdadera ejecución de la transacción.

El gas

El siguiente paso es decidir cuánto estás dispuesto a pagar por la transacción. Porque todas las transacciones deben pagarle una tarifa a la red de nodos que gasta tiempo y recursos para ejecutarlas y validarlas.

El costo de ejecutar una transacción se paga en ETH. Y el importe final de ETH va a depender de cuánto gas neto consuma tu transacción (es decir, qué tan costosa es en términos de procesamiento), de cuánto estás dispuesto a pagar por cada unidad de gas gastada, y de cuánto está dispuesta a aceptar la red como mínimo.

Desde la perspectiva del usuario, la conclusión, por lo general, es que mientras más se pague, más rápido se incluyen las transacciones. Si querés transferirle 1 DAI a Vitalik en el próximo bloque, probablemente tengas que establecer una tarifa más alta que la que fijarías si estuvieras dispuesto a esperar un par de minutos (o más, a veces, mucho más), hasta que el gas sea más económico.

Otras billeteras pueden usar otras estrategias a la hora de decidir cuánto pagar por el gas. No conozco un único mecanismo que sea a prueba de balas y lo use todo el mundo. Las estrategias para determinar las tarifas adecuadas pueden involucrar consultas de información relacionada con el gas a los nodos (como la tarifa base mínima que la red acepte).

Por ejemplo, en las siguientes solicitudes, podés ver cómo la extensión del navegador Metamask le solicita a mi nodo de prueba local las tarifas de gas al momento de crear una transacción:

Tráfico de Metamask consultado un nodo por datos relacionados a gas

Y la solicitud-respuesta simplificada se ve así:

POST / HTTP/1.1
Content-Type: application/json
Content-Length: 99

{
    "id":3951089899794639,
    "jsonrpc":"2.0",
    "method":"eth_feeHistory",
    "params":["0x1","0x1",[10,20,30]]
}
---
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 190

{
    "jsonrpc":"2.0",
    "id":3951089899794639,
    "result":{
        "oldestBlock":"0x1",
        "baseFeePerGas":["0x342770c0","0x2da4d8cd"],
        "gasUsedRatio":[0.0007],
        "reward":["0x59682f00","0x59682f00","0x59682f00"]]
    }
}

Algunos nodos exponen el endpoint eth_feeHistory para permitir la consulta de datos sobre las tarifas por transacción. Si te interesa, leé esto o divertite con eso acá,] o mirá estas especificaciones.

Las billeteras populares también usan servicios por fuera de la cadena para recuperar estimaciones del precio del gas y sugerir a sus usuarios valores razonables. A continuación se muestra un ejemplo de una billetera que alcanza el endpoint público de un servicio web y recibe un montón de datos relacionados con el gas:

Tráfico en Wireshark incluyendo solicitud eth_feeHistory

Fíjate el siguiente fragmento de la respuesta:

Tráfico en Wireshark incluyendo respuesta de eth_feeHistory

Interesante, ¿no?

Espero que te estés familiarizando con la idea de que establecer los precios de la tarifa de gas no es tan sencillo. Pero que es un paso fundamental para crear una transacción exitosa. Incluso si lo único que querés hacer es enviar 1 DAI. Acá hay una guía introductoria bastante interesante para investigar más a fondo algunos de los mecanismos que se usan para establecer tarifas más precisas en las transacciones.

Habiendo contextualizado un poco, volvamos a la transacción real. Hay que establecer los tres campos relacionados con el gas que se encuentran a continuación:

{
    "maxPriorityFeePerGas": ...,
    "maxFeePerGas": ...,
    "gasLimit": ...,
}

Las billeteras usan algunos de los mecanismos mencionados para completar los dos primeros campos por vos. Cuando la interfaz de usuario de una billetera te deja elegir entre transacciones «lentas», «regulares» o «rápidas», en realidad, está tratando de decidir qué valores son los más adecuados para esos parámetros de ahí arriba. Ahora podés entender mejor el contenido de la respuesta en formato JSON que recibe la billetera que te mostré un par de párrafos atrás.

Para determinar el valor del tercer campo, el límite de gas, existe un mecanismo muy práctico que las billeteras usan para simular una transacción antes de que realmente se envíe. Este mecanismo les permite estimar con precisión la cantidad de gas que consumiría una transacción y, por lo tanto, pueden establecer un límite de gas razonable. Aparte de brindarte una estimación del costo total de la transacción en USD (o tu moneda local).

¿Por qué no fijar un límite de gas alto y listo? ¿Para qué tanto lío? Para proteger tus fondos, por supuesto. Los contratos inteligentes pueden tener una lógica arbitraria y sos vos el que paga por su ejecución. Al elegir un límite de gas prudente para tu transacción, te estás salvando de escenarios bastante feos que podrían drenar todos los fondos de ETH de tu cuenta por pagar excesivas tarifas de gas.

Las estimaciones de gas se pueden realizar mediante el endpoint de un nodo denominado eth_estimateGas. Antes de enviar 1 DAI, una billetera puede aprovechar este mecanismo para simular tu transacción y determinar cuál es el límite de gas adecuado para tu transferencia de DAI. Así podría verse la solicitud-respuesta de una billetera.

POST / HTTP/1.1
Content-Type: application/json

{
    "id":2697097754525,
    "jsonrpc":"2.0",
    "method":"eth_estimateGas",
    "params":[
        {
            "from":"0x6fC27A75d76d8563840691DDE7a947d7f3F179ba",
            "value":"0x0",
            "data":"0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000",
            "to":"0x6b175474e89094c44da98b954eedeac495271d0f"
        }
    ]
}
---
HTTP/1.1 200 OK
Content-Type: application/json

{"jsonrpc":"2.0","id":2697097754525,"result":"0x8792"}

En la respuesta, podés ver que la transferencia podría consumir alrededor de 34 706 unidades de gas (8792 en hexadecimal).

Incorporemos esta información a la transacción:

{
    "to": "0x6b175474e89094c44da98b954eedeac495271d0f",
    "amount": 0,
    "chainId": 31337,
    "nonce": 0,
    "data": "0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000",
    "maxPriorityFeePerGas": 2000000000,
    "maxFeePerGas": 120000000000,
    "gasLimit": 40000
}

Recordá que maxPriorityFeePerGas (tarifa de prioridad máxima por unidad de gas) y maxFeePerGas (tarifa máxima por unidad de gas) dependen de las condiciones de la red al momento de enviar la transacción. Arriba estoy poniendo valores un tanto arbitrarios para este ejemplo. En cuanto al valor establecido para el límite de gas, sólo incrementé un poquito el valor de la estimación. Como para no errarle.

Lista de acceso y tipo de transacción

Veamos brevemente dos campos adicionales que se establecen en la transacción.

Primero, el campo accessList (lista de acceso). Sirve para hacer la transacción más económica, en algunos casos de uso avanzados, o escenarios muy poco usuales. En esta lista de acceso se especifica de antemano las direcciones de las cuentas y las ranuras de almacenamiento de los contratos a los que se va a acceder.

Sin embargo, puede que no sea tan sencillo crear dicha lista con anticipación. En la actualidad, es posible que los ahorros de gas no sean muy significativos. Sobre todo para transacciones simples como el envío de 1 DAI. Por lo que podemos dejarla como una lista vacía. Sin embargo, acordate que existe por una razón, y en un futuro puede ser más relevante.

Segundo, el tipo de transacción. Se especifica en el campo type (tipo). Este campo es un indicador de lo que contiene la transacción. La nuestra va a ser una transacción de tipo 2, porque sigue el formato especificado acá.

{
    "to": "0x6b175474e89094c44da98b954eedeac495271d0f",
    "amount": 0,
    "chainId": 31337,
    "nonce": 0,
    "data": "0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000",
    "maxPriorityFeePerGas": 2000000000,
    "maxFeePerGas": 120000000000,
    "gasLimit": 40000,
    "accessList": [],
    "type": 2
}

Firma de la transacción

¿Cómo hacen los nodos para saber que es cuenta la que está enviando la transacción y no la de alguien más?

Llegamos al paso esencial de la creación de una transacción válida: firmarla.

Una vez que una billetera recolectó la información suficiente como para crear la transacción, y apretás ENVIAR, esta va a firmar tu transacción de forma digital. ¿Cómo? Mediante el uso de la clave privada de tu cuenta (a la cual tu billetera tiene acceso) y un algoritmo criptográfico que incluye una lineas curvas psicodélicas denominado Algoritmo de Firma Digital de Curva Elíptica (ECDSA, Elliptic Curve Digital Signature Algorithm).

Para los y las más nerds, lo que en verdad se está firmando es el hash keccak256 de la concatenación entre el tipo de transacción y el contenido de la transacción usando RPL (Prefijo de Longitud Recursiva).

keccak256(0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, amount, data, accessList]))

Sin embargo, no hace falta tener 53 doctorados en criptografía para entender esto. Pongámoslo en términos simples. Este proceso sella la transacción. La hace inalterable poniéndole una firma bien piola que sólo se pudo haber producido con tu clave privada. Y, a partir de ahora, quién sea que tenga acceso a esa transacción firmada (como por ejemplo, los nodos de Ethereum) puede verificar a través de unas técnicas criptográficas que fue tu cuenta la que la produjo y firmó.

Por si acaso: firmar una transacción digitalmente no quiere decir encriptarla. Son dos cosas muy distintas. Tus transacciones están siempre en texto plano. Una vez que se hacen públicas, cualquiera puede interpretar el contenido.

El proceso de firma de la transacción produce, sorpresa, una firma. En la práctica: un montón de valores bien raros e inentendibles. Estos viajan junto a la transacción, y en general se conocen como v, r y s. Si querés entender en mayor detalle qué representan en realidad y la importancia que tienen para recuperar la dirección de tu cuenta, la Internet es tu aliada.

Podemos hacernos una mejor idea de cómo se vería el mecanismo de firma implementado en código utilizando el paquete \@ethereumjs/tx. Junto con ethers para algunas utilidades. A modo de ejemplo simplificado, firmar la transacción para enviar 1 DAI podría verse así:

const { FeeMarketEIP1559Transaction } = require("@ethereumjs/tx");

const txData = {
    to: "0x6b175474e89094c44da98b954eedeac495271d0f",
    amount: 0,
    chainId: 31337,
    nonce: 0,
    data: "0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000",
    maxPriorityFeePerGas: ethers.utils.parseUnits('2', 'gwei').toNumber(),
    maxFeePerGas: ethers.utils.parseUnits('120', 'gwei').toNumber(),
    gasLimit: 40000,
    accessList: [],
    type: 2,
};

const tx = FeeMarketEIP1559Transaction.fromTxData(txData);
const signedTx = tx.sign(Buffer.from(process.env.PRIVATE_KEY, 'hex'));

console.log(signedTx.v.toString('hex'));
// 1

console.log(signedTx.r.toString('hex'));
// 57d733933b12238a2aeb0069b67c6bc58ca8eb6827547274b3bcf4efdad620a

console.log(signedTx.s.toString('hex'));
// e49937ec81db89ce70ebec5e51b839c0949234d8aad8f8b55a877bd78cc293

El objeto resultante se vería de la siguiente manera:

{
    "to": "0x6b175474e89094c44da98b954eedeac495271d0f",
    "amount": 0,
    "chainId": 31337,
    "nonce": 0,
    "data": "0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000",
    "maxPriorityFeePerGas": 2000000000,
    "maxFeePerGas": 120000000000,
    "gasLimit": 40000,
    "accessList": [],
    "type": 2,
    "v": 1,
    "r": "57d733933b12238a2aeb0069b67c6bc58ca8eb6827547274b3bcf4efdad620a",
    "s": "e49937ec81db89ce70ebec5e51b839c0949234d8aad8f8b55a877bd78cc293",
}

Serialización

El siguiente paso consiste en serializar la transacción firmada. Significa codificar ese hermoso objeto de arriba en una secuencia de bytes. De modo que se pueda enviar a la red de Ethereum y los nodos receptores la puedan consumir.

El método de codificación elegido por Ethereum se llama RLP. La manera en la que se codifica la transacción es la siguiente:

0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, v, r, s])

Donde el byte inicial (0x02) indica el tipo de transacción.

De hecho, aprovechando el código previo, podés ver la transacción serializada si le agregás esto:

console.log(signedTx.serialize().toString('hex'));
// 02f8b1827a69808477359400851bf08eb000829c40946b175474e89094c44da98b954eedeac495271d0f80b844a9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000c001a0057d733933b12238a2aeb0069b67c6bc58ca8eb6827547274b3bcf4efdad620a9fe49937ec81db89ce70ebec5e51b839c0949234d8aad8f8b55a877bd78cc293

Ese conjunto de caracteres hexadecimales que empieza con 02f8 es la transacción completa, firmada y serializada. Es todo lo que necesito para enviarle 1 DAI a Vitalik en mi copia local de la red principal de Ethereum.

Envío de la transacción

Una vez construida, firmada y serializada, la transacción se debe enviar a un nodo de Ethereum.

Existe un endpoint JSON-RPC muy práctico que los nodos pueden exponer y en donde pueden recibir dichas solicitudes. Se llama eth_sendRawTransaction. A continuación se muestra el tráfico de red de una billetera al emplearlo tras el envío de la transacción:

Tráfico de Wireshark enviando una transacción serializada usando el método eth_sendRawTransaction

La solicitud-respuesta se puede resumir así:

POST / HTTP/1.1
Content-Type: application/json
Content-Length: 446

{
    "id":4264244517200,
    "jsonrpc":"2.0",
    "method":"eth_sendRawTransaction",
    "params":["0x02f8b1827a69808477359400851bf08eb000829c40946b175474e89094c44da98b954eedeac495271d0f80b844a9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000c001a0057d733933b12238a2aeb0069b67c6bc58ca8eb6827547274b3bcf4efdad620a9fe49937ec81db89ce70ebec5e51b839c0949234d8aad8f8b55a877bd78cc293"]
}
---
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 114

{
    "jsonrpc":"2.0",
    "id":4264244517200,
    "result":"0xbf77c4a9590389b0189494aeb2b2d68dc5926a5e20430fb5bc3c610b59db3fb5"
}

El resultado que se incluye en la respuesta contiene el hash de la transacción: bf77c4a9590389b0189494aeb2b2d68dc5926a5e20430fb5bc3c610b59db3fb5. Esta secuencia de caracteres hexadecimales de 32 bytes de longitud es el identificador unívoco de la transacción.

Recepción

¿Cómo se debería abordar el estudio de lo que pasa cuando un nodo de Ethereum recibe la transacción serializada y firmada?

Es posible que algunas personas hagan preguntas en Twitter. Otras quizás lean algunos artículos en Medium. Incluso es probable que otras personas vayan y lean la documentación. Shame!

La máxima verdad sólo se encuentra en la fuente. Vayamos ahí entonces. Al código.

Usemos go-ethereum v1.10.18 (Geth para los amigos), una implementación popular de un nodo de Ethereum (un «cliente de ejecución» una vez que Ethereum pase a Proof of Stake). A partir de ahora, voy a incluir links al código fuente de Geth para que puedas ir siguiendo el análisis.

Al recibir la llamada JSON-RPC en su endpoint eth_sendRawTransaction, el nodo tiene que interpretar la transacción serializada que está incluida en el cuerpo de la solicitud. Es por eso que comienza por deserializar la transacción. De ahora en adelante, el nodo va a tener acceso a los campos de la transacción.

En este punto ya comienza la validación de la transacción. En primer lugar, se asegura de que la tarifa a pagar por el usuario (es decir, precio por unidad de gas x límite de gas) no exceda el máximo que el nodo está dispuesto a aceptar (aparentemente, lo predeterminado es 1 ether). Y, luego, se asegura de que la transacción esté protegida contra ataques de repetición (según el estándar EIP 155, ¿te acordás del campo chainID que fijamos en la transacción?) o que el nodo esté dispuesto a aceptar transacciones sin esta protección.

El siguiente paso consiste en enviar la transacción a la pool de transacciones (también conocida como «mempool»). En términos simples, la mempool representa el conjunto de transacciones que el nodo reconoce en un momento específico. Hasta donde sabe el nodo, estas aún no se han incluido en la blockchain.

Antes de incluir la transacción en la mempool, el nodo verifica que todavía no la reconoce. Y que la firma del ECDSA sea válida. De lo contrario, descarta la transacción.

Sólo entonces comienza el trabajo pesado de la mempool. Como ves, hay un montón de lógica no trivial para garantizar que esté muy feliz y sana. En criollo, tremendo quilombo.

Hay muchas validaciones importantes que pasan acá. Ejemplos: que el límite de gas esté por debajo del límite de gas del bloque, o que el tamaño de la transacción no exceda el máximo permitido, o que el nonce sea el esperado, o que el emisor tenga fondos suficientes para cubrir los costos posibles (es decir, valor enviado en la llamada + límite de gas x precio de unidad de gas). Y más.

Podríamos seguir, pero no estamos acá para volvernos expertos en la mempool. Incluso si quisiéramos, tendríamos que considerar que, mientras sigan las reglas de consenso de la red, el operador de cada nodo puede adoptar diferentes estrategias para la administración de su propia mempool. Eso significa efectuar validaciones especiales o seguir reglas arbitrarias de priorización de transacciones. Ya que por hoy sólo nos interesa enviar 1 DAI, pensemos a la mempool como un simple conjunto de transacciones que esperan ansiosas a ser seleccionadas e incluidas en un bloque.

Luego de añadir la transacción a la pool correctamente, el nodo devuelve el hash de la transacción. Ni más ni menos que lo que devolvió la solicitud-respuesta HTTP que vimos más arriba. 😎

Inspección de la mempool

Si enviás una transacción a través de Metamask o cualquier otra billetera similar que esté conectada a nodos "tradicionales" de forma predeterminada, en algún punto llegará a las mempools de los nodos públicos.

No me creas. Podés comprobarlo vos mismo inspeccionando mempools públicas.

Existe un endpoint muy práctico que algunos nodos exponen, denominado eth_newPendingTransactionFilter. Tal vez un viejo amigo de los bots de frontrunning. A través de consultas periódicas a este endpoint podríamos visualizar la transacción que envía 1 DAI en la mempool de un nodo de prueba local, antes de ser incluida en la blockchain.

En código Javascript, esto se puede lograr con el siguiente script:

const hre = require("hardhat");

hre.ethers.provider.on('pending', async function (tx) {
    // hacer algo con la transacción
});

Para ver la verdadera llamada eth_newPendingTransactionFilter, podemos inspeccionar el tráfico de red.

Tráfico de Wireshar con llamada JSON-RPC suscribiendo a transacciones pendientes

A partir de ahora el script consultará los cambios en la mempool automáticamente. A continuación se muestra la primera de muchas llamadas periódicas posteriores solicitando cambios en la mempool:

Tráfico de Wireshark con llamada JSON-RPC consultando por cambios en la mempool

Y tras efectivamente recibir la transacción, acá está la respuesta con el hash:

Tráfico de Wireshark con llamada JSON-RPC respondiendo con el hash de la transacción detectada

La solicitud-respuesta HTTP se puede resumir así:

POST / HTTP/1.1
Content-Type: application/json
content-length: 74

{
    "jsonrpc":"2.0",
    "method":"eth_getFilterChanges",
    "params":["0x1"],
    "id":58
}
---
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 105

{
    "jsonrpc":"2.0",
    "id":58,
    "result":["0xbf77c4a9590389b0189494aeb2b2d68dc5926a5e20430fb5bc3c610b59db3fb5"]
}

Antes mencioné «nodos tradicionales» sin explicarlo demasiado. Con esto quiero decir que existen nodos más especializados que tienen mempools privadas. Permiten que los usuarios puedan ocultar transacciones del público antes de que se incluyan en un bloque.

Más allá de los detalles, estos mecanismos consisten en establecer canales privados entre emisores de transacciones y creadores de bloques. El servicio Flashbots Protect es un ejemplo paradigmático. La consecuencia práctica es que, incluso si estás supervisando mempools con el método que se mostró arriba, no vas a poder recuperar las transacciones que llegan a los productores de bloques mediante canales privados.

A fines prácticos vamos a suponer que la transacción para enviar 1 DAI se hace por la red a través de canales normales, sin hacer uso de este tipo de servicios.

Propagación

Para que la transacción se incluya en un bloque necesita llegar a los nodos que puedan construirlo y proponerlo a la red. En el Ethereum de Proof of Work, estos se llaman mineros. En el Ethereum de Proof of Stake, se llaman validadores. Aunque la realidad suele ser un poco más compleja. Tené en cuenta que hay formas a través de las cuales se les puede delegar la producción de bloques a servicios especializados.

Como usuarios común y corriente, no tenés que preocuparte por quiénes producen estos bloques, ni dónde están ubicados. Es más fácil. Podés enviar una transacción válida a cualquier nodo regular en la red, dejar que se incluya en la pool de transacciones y tomarte un café mientras los protocolos peer-to-peer de Ethereum hagan su trabajo.

Existen varios protocolos peer-to-peer (p2p, por sus siglas en inglés) que interconectan los nodos de Ethereum. Permiten, entre otras cosas, el intercambio frecuente de transacciones.

Ya desde un primer momento, todos los nodos escuchan y emiten transacciones junto con sus pares (de forma predeterminada, cada nodo tiene 50 pares como máximo).

Una vez que la transacción llega a la mempool, se envía a todos los pares conectados que aún no están al tanto de la transacción.

Para más eficiencia, la transacción completa se envía a un subconjunto aleatorio de nodos conectados (la raíz cuadrada🤓). Los nodos restantes reciben hashes de transacciones. Estos podrían pedir la transacción completa si es necesario.

Una transacción no puede permanecer en la mempool de un nodo de forma permanente. Si no se descarta al principio por otras razones (por ej., la pool está completa y a la transacción se le asignó un precio demasiado bajo o se la reemplazó por una nueva con un precio o nonce mayor), puede que sea eliminada de manera automática después de cierto tiempo (de forma predeterminada, después de 3 horas).

Existe una lista de transacciones pendientes que permite hacer un seguimiento de las transacciones válidas en la mempool que se consideran listas para ser implementadas y procesadas por un productor de bloques. Los productores pueden consultar dicha lista para obtener las transacciones factibles de ser procesadas que pueden entrar en la cadena de bloques.

Preparación del trabajo e inclusión de la transacción

La transacción debería alcanzar un nodo minero (al menos en Proof of Work) luego de haber navegado por las mempools. Este tipo de nodos hace muchas tareas al mismo tiempo. Para quienes conocen el lenguaje de programación Golang, esto se traduce en un gran número de channels y go-routines desparramados por toda la lógica del código de minado. Para quienes no conocen el lenguaje de programación Golang, esto significa que las operaciones de los mineros no se pueden explicar de una forma tan lineal como me gustaría.

Esta sección tiene objetivo doble. Primero, entender cómo y cuándo un minero recoge nuestra transacción de la mempool. Segundo, descubrir en qué momento empieza la ejecución de la transacción.

Ocurren al menos dos cosas relevantes cuando el módulo de minería del nodo se inicializa. Por un lado, se pone a la escucha de la llegada de nuevas transacciones a la mempool. Por el otro, se activan algunos loops fundamentales.

En términos de Geth, el acto de crear un bloque con transacciones y sellarlo se denomina «aplicar un trabajo». Queremos entender bajo qué circunstancias ocurre esto.

Fijate en el loop llamado «new work». Esta es una rutina independiente que, a partir de que el nodo reciba distintos tipos de notificaciones, dispara nuevos trabajos. Disparar un trabajo implica enviar una petición de trabajo a otro de los listeners activos del nodo (que corre en el loop «principal»] de los mineros). Cuando la petición se recibe, comienza la aplicación del trabajo.

Ahora ocurre una preparación inicial. Consiste, principalmente, en crear el encabezado (o header) del bloque. Esto incluye tareas como encontrar el bloque padre; asegurarse de que el timestamp del bloque que se está construyendo sea correcto, y fijar el número del bloque, el límite de gas, la dirección de coinbase y la tarifa base (base fee).

Luego se invoca al motor de consenso para que lleve a cabo la «preparación de consenso» del encabezado. Este proceso calcula la dificultad exacta del bloque (en función de la versión actual de la red). Si alguna vez escuchaste hablar sobre la «bomba de dificultad» de Ethereum, ahí la tenés.

A continuación, se crea el contexto de sellado del bloque. Dejando de lado otras acciones, esto consiste en recuperar el último estado conocido de la cadena. Este es el estado sobre el cual se va a ejecutar la primera transacción del bloque en creación. Esa podría ser nuestra transacción que envía 1 DAI.

Habiendo preparado el bloque, ahora se llena de transacciones.

Por fin llegamos al momento en el que se selecciona nuestra transacción pendiente, que hasta el momento estaba esperando plácidamente en la mempool del nodo.

Por defecto, las transacciones se ordenan dentro de un bloque según el precio y el nonce. Aunque en nuestro caso la posición de la transacción dentro del bloque es prácticamente irrelevante.

Ahora comienza la ejecución secuencial de las transacciones. Se ejecuta una transacción tras otra, cada una de ellas aplicada sobre el estado resultante de la anterior.

Ejecución

Se puede pensar una transacción de Ethereum como una transición de estados.

Estado 0: tenés 100 DAI y Vitalik también tiene 100.

Transacción: le enviás 1 DAI a Vitalik.

Estado 1: tenés 99 DAI y Vitalik tiene 101.

Por ende, podemos decir que ejecutar una transacción es aplicar una serie de operaciones sobre el estado actual de la cadena de bloques. Como resultado, se produce un estado nuevo. Diferente al anterior. Este será visto como el nuevo estado actual hasta que aparezca otra transacción que lo modifique.

En la práctica esto es mucho más interesante (y complejo). Veamos.

Preparación (primera parte)

En la jerga de Geth, los mineros hacen commits de transacciones en el bloque. El proceso de hacer un commit de una transacción se realiza en un entorno. El entorno contiene, entre otras cosas, un estado.

Dicho fácil, el commit de una transacción comprende 3 pasos: (1) recordar el estado actual, (2) aplicarle la transacción, (3) dependiendo del éxito de la transacción, aceptar el nuevo estado o volver al estado original.

Lo esencial ocurre en el paso 2, cuando se aplica la transacción.

Lo primero que se observa es que la transacción se convierte en un «mensaje». Si viste alguna vez código Solidity, donde se escriben cosas como msg.data o msg.sender, leer la palabra «mensaje» en el código de Geth es LA señal de que estás adentrándote en tierras un poco más conocidas.

Examinar cómo se ve un mensaje nos lleva rápidamente a observar al menos una diferencia con una transacción. ¡Un mensaje tiene un campo from! El campo es la dirección de Ethereum de quien firma, que deriva de la firma pública incluida en la transacción (¿te acordás de esos campos raros v, r y s?).

El nodo continúa ahora preparando aún más el entorno de ejecución. En primer lugar, se crea el contexto relacionado con el bloque, lo que incluye cosas como el número de bloque, el timestamp, la dirección de coinbase y el límite de gas de un bloque. Y después...

Entra la bestia.

La máquina virtual de Ethereum (EVM, Ethereum Virtual Machine), un motor de procesamiento de 256 bits basado en pilas de datos que se encarga de ejecutar la transacción, se asoma ahí bien chill como si no pasara nada, y arranca a vestirse. Sisi, entró desnuda. Es la EVM, ¿qué esperabas?

La EVM es una máquina. Y como tal, dispone de una serie de instrucciones (también conocidas como códigos operacionales, o opcodes en inglés) que puede ejecutar. El set de instrucciones fue cambiando a través de los años. Por lo que tiene que haber un pedazo de código que le indique a la EVM qué instrucciones debería usar hoy. Y obviamente, está. Cuando la EVM instancia a su intérprete, elige el set de operaciones, según la versión que se esté usando.

Por último, los dos pasos finales antes de la verdadera ejecución. Se crea el contexto de transacción de la EVM (¿alguna vez usaste tx.origin o tx.gasPrice en tus contratos inteligentes de Solidity?) y, luego, la EVM obtiene acceso al estado actual.

Preparación (segunda parte)

Es momento de que la EVM realice la transición de estado. Dado un mensaje, un entorno y el estado original, la EVM va a usar una limitada serie de instrucciones para pasar a un nuevo estado. Idealmente, uno en el que Vitalik tenga 1 DAI más💰.

Antes de que se aplique la transición de estado, la EVM debe asegurarse de que cumple con las reglas específicas de consenso. Veamos esto con más detalle.

La validación comienza en lo que Geth llama el «pre-check» (verificación previa). Consiste en:

Validar el nonce del mensaje. Este debe coincidir con el nonce de la dirección from del mensaje. Además, no debe ser el máximo nonce posible (chequeando que al incrementar el nonce no se cause un overflow).

Asegurarse de que la cuenta correspondiente a la dirección from del mensaje no tenga código. Es decir, que el origen de la transacción sea una cuenta con propiedad externa (mejor conocida como EOA, externally-owned account) y que, por lo tanto, cumpla con las especificaciones de la EIP 3607.

Verificar que los campos maxFeePerGas (el gasFeeCap en Geth) y maxPriorityFeePerGas (el gasTipCap en Geth) que se fijaron en la transacción estén dentro de los límites previstos. Además, que la tarifa de prioridad no sea mayor que la máxima permitida y que el maxFeePerGas sea mayor que la tarifa base del bloque actual.

Comprar gas. Verificando que la cuenta pueda pagar por todo el gas que pretende consumir y que aún quede suficiente gas en el bloque para procesar la transacción. Por último, forzando el pago de gas por adelantado (no te preocupés, después hay algunos mecanismos de reembolso).

A continuación, la EVM tiene en cuenta el «gas intrínseco» que consume la transacción. Existen algunos factores que se deben considerar a la hora de calcular el gas intrínseco. Para comenzar, si la transacción es una creación de contrato o no. La nuestra no lo es, así que el gas empieza en 21 000 unidades. Luego, contabilizando la cantidad de bytes distintos de cero en el campo data del mensaje. Se cobran 16 unidades de gas por cada byte distinto de cero (de acuerdo con esta especificación). Se cobran solamente 4 unidades por cada byte que sea cero. Por último, se contabilizaría un poco más de gas por adelantado so hubiésemos proporcionado listas de acceso.

Establecimos el campo value de la transacción en cero. Si hubiésemos especificado un valor positivo, ahora sería el momento de que la EVM verifique si la cuenta emisora tiene suficiente balance para ejecutar la transferencia de ETH. También, si hubiésemos proporcionado listas de acceso, ahora se inicializarían en el estado.

La transacción en ejecución no está creando un contrato. La EVM lo sabe, porque el campo to no es cero. Por lo tanto, incrementará el nonce de la cuenta del emisor en uno, y ejecutará una llamada.

La llamada irá desde las dirección from del mensaje a la dirección to, adjuntando el campo data, sin ningún valor de ETH, y lo que sea que quede de gas una vez consumido el gas intrínseco.

La llamada

(no esta llamada)

El contrato inteligente de DAI está almacenado en la dirección 0x6b175474e89094c44da98b954eedeac495271d0f. Esa es la dirección que fijamos en el campo to de la transacción. Esta llamada inicial se realiza para que la EVM ejecute cualquier código que esté almacenado ahí. Instrucción por instrucción.

Las instrucciones de la EVM se pueden representar en números hexadecimales, que van desde 00 hasta FF. Aunque, generalmente, se los llama por sus nombres. Por ejemplo, 00 es STOP (detener) y FF es SELFDESTRUCT (autodestruir). Hay una lista bastante práctica en evm.codes.

Entonces ¿cuales son las instrucciones de DAI? Me alegra tanto que hayas preguntado:

Instrucciones de EVM del contrato inteligente de DAI

Pará pará. No cerrés todo a la mierda.

Ya vamos a llegar a entender (una parte) de todo eso.

Por ahora sigamos en Geth, y empecemos a desglosar la llamada inicial que mencioné antes. Si traducimos la documentación del código, tenemos un buen resumen. Nos dice que la función Call ejecuta el contrato asociado a la dirección que llega en el parámetro addr, usando los datos que llegan en el parámetro input. También maneja cualquier transferencia de ETH que sea necesaria, crea cuentas si hace falta, y revierte el estado si hay errores de ejecución o fallan las transferencias de ETH.

// Call executes the contract associated with the addr with the given input as
// parameters. It also handles any necessary value transfer required and takes
// the necessary steps to create accounts and reverses the state in case of an 
// execution error or failed value transfer.
func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftOverGas uint64, err error) {
    ...
}

Por empezar, la lógica verifica que la llamada no alcance el call depth (profundidad de llamadas). Este límite se estableció en 1024, lo que significa que puede haber un máximo de 1024 llamadas anidadas en una transacción. Acá podes leer un artículo interesante sobre algunos de los razonamientos y las sutilezas por detrás de este comportamiento de la EVM.

Nota al margen: el límite de call depth no es el límite de tamaño del stack de la EVM, que (¿casualmente?) también es de 1024 elementos.

El siguiente paso consiste en comprobar que si se especificó un valor de ETH positivo en la llamada, el emisor tenga el balance suficiente como para ejecutar la transferencia (efectuada un poco más tarde). Podemos ignorar esto porque el valor de ETH de nuestra llamada es cero. Además, se toma una captura (conocida como snapshot) del estado actual, lo que permite revertir fácilmente todos los cambios de estado en caso de fallos.

Sabemos que la dirección de DAI remite a una cuenta que tiene código almacenado. Entonces, ya debe existir en el estado de Ethereum.

Ahora bien, imaginemos por un momento que esta no es una transacción para enviar 1 DAI. Digamos que es una transacción sin ningún valor de ETH asociado que se dirige a una dirección nueva. La cuenta correspondiente necesitaría añadirse al estado. Sin embargo, ¿qué pasaría si dicha cuenta terminase estando vacía? No hay razón alguna para mantenerla en el estado, más allá de desperdiciar espacio de almacenamiento en disco de los nodos. EIP 158 introdujo algunos cambios al protocolo Ethereum para ayudar a evitar estos escenarios. Es por eso que estás viendo esta cláusula if al llamar a cualquier cuenta.

Otra cosa que sabemos es que DAI no es un contrato precompilado. ¿Qué es un contrato precompilado? Acá está lo que el Yellow Paper de Ethereum puede ofrecer:

[...] una pieza de arquitectura preliminar que podría convertirse en extensiones nativas en un futuro. Los contratos en las direcciones 1 a 9 ejecutan la función de recuperación de la clave pública por curva elíptica, el esquema de hash SHA2 de 256 bits, el esquema de hash RIPEMD de 160 bits, la función de identidad, la exponenciación modular de precisión arbitraria, la adición de curva elíptica, la multiplicación escalar de curva elíptica, una verificación de emparejamiento de curva elíptica y la función F de compresión BLAKE2 respectivamente.

En resumen, existen (hasta ahora) 9 contratos especiales distintos en el estado de Ethereum. Estas cuentas (desde 0x0000000000000000000000000000000000000001 a 0x0000000000000000000000000000000000000009) incluyen el código necesario para ejecutar las operaciones mencionadas en el Yellow Paper. Por supuesto, podés verificar esto por tu cuenta en el código de Geth.

Para añadir un poco de color a la historia de los contratos precompilados, fijate que en la red principal de Ethereum todas estas cuentas tienen, por lo menos, 1 wei en su balance. Esto se hizo de manera intencional (por lo menos antes de que los usuarios empezaran a enviar Ether por error). Mirá, acá hay una transacción de hace 5 años que envió 1 wei al precompilado 0x0000000000000000000000000000000000000009.

Al notar que la cuenta de destino de la llamada no se corresponde con un contrato precompilado, el nodo lee el código de la cuenta desde el estado. Luego se asegura de que no esté vacía. Por último, le pide a la EVM que use su intérprete para ejecutar el código con la entrada determinada (los contenidos del campo data de la transacción).

El intérprete (primera parte)

Llegó el momento de que la EVM ejecute el código de DAI. La EVM tiene algunos elementos a su alcance para lograrlo. Tiene un stack que puede contener hasta 1024 elementos (aunque sólo pueda acceder de manera directa a los primeros 16 con las instrucciones disponibles). Tiene un espacio de memoria de lectura y escritura volátil. Tiene un contador de programa. Tiene un espacio de memoria de sólo lectura (conocido como calldata), donde se mantienen los datos de entrada de la llamada. Entre otras cosas.

Como es habitual, existen algunas configuraciones y validaciones que son necesarias antes de lo más interesante. Primero, se incrementa el call depth en uno. Segundo, se configura el modo de sólo lectura, de ser necesario. La nuestra no es una llamada de sólo lectura (mirá acá cómo se pasó el argumento false). De lo contrario, no se permitirían algunas operaciones de la EVM. Entre estas operaciones, se incluyen las instrucciones de la EVM que cambian el estado SSTORE, CREATE, CREATE2, SELFDESTRUCT, CALL con valor positivo y LOG.

El intérprete ahora entra al loop de ejecución. Consiste en ejecutar las instrucciones en el código de DAI de manera secuencial, según lo que indique el contador de programa y el conjunto de instrucciones actual de la EVM. Por el momento estamos usando el conjunto de instrucciones London, el cual se configuró en la tabla de saltos cuando se instanció el interprete.

El loop también se encarga de mantener un stack en buen estado (lo que evita valores demasiado altos o bajos) y de contabilizar los costos de gas fijos de cada operación, así como los costos de gas dinámicos cuando corresponda. Los costos dinámicos incluyen, por ejemplo, la expansión de la memoria de la EVM (para obtener más información acerca de cómo se calculan los costos de la expansión de la memoria, hacé clic acá). Notá que el gas no se consume después de la ejecución de una instrucción. Se consume antes.

El comportamiento de cada instrucción disponible en la EVM está implementado en este archivo de Geth. Con tan solo ojear ese archivo, uno ya puede ver cómo estas instrucciones trabajan con el stack, la memoria, los datos de la llamada y el estado.

En este punto necesitaríamos pasar directamente a las instrucciones de bajo nivel de DAI y seguir paso a paso su ejecución para los datos de nuestra transacción. Sin embargo, no creo que esa sea la mejor manera de abordar esto. Prefiero, primero, dejar un poco de lado la EVM y Geth, y pasar al terreno de Solidity. Esto debería darnos un pantallazo más útil del comportamiento de alto nivel de una operación de transferencia ERC20.

Ejecución en Solidity

El contrato inteligente de DAI se programó en Solidity. Es un lenguaje de alto nivel orientado a objetos que, cuando se compila, produce el código de bajo nivel capaz de crear contratos inteligentes en una cadena compatible con la EVM (en nuestro caso, Ethereum).

El código fuente de DAI se puede encontrar verificado en los exploradores de bloques, o en GitHub.

Antes de empezar, tengamos siempre en cuenta que la EVM no conoce Solidity. No conoce sus variables, funciones, formato de los contratos, codificación por ABI, etcétera. La blockchain de Ethereum almacena código de bajo nivel puro y duro que la EVM puede entender. Nunca código sofisticado de alto nivel como Solidity.

Dicho eso, puede que te preguntes entonces por qué cuando usás cualquier explorador de bloques, te muestran código en Solidity en las cuentas de Ethereum. Bueno, en realidad, es una fachada. En la mayoría de los exploradores de bloques, las personas pueden subir código fuente en Solidity, y el explorador se ocupa de compilar el código fuente con las configuraciones específicas del compilador. Si la salida del compilador que produce el explorador coincide con lo que está almacenado en una dirección específica de la blockchain, entonces se dice que el código fuente del contrato está «verificado». A partir de ese momento, cualquiera que navegue a esas direcciones va a ver el código de esa dirección en Solidity, en vez del código de la EVM que realmente está almacenado ahí.

Una consecuencia no trivial de esto es que, hasta cierto punto, confiamos en que los exploradores de bloques nos muestran el código legítimo (lo que puede no ser necesariamente cierto, incluso por accidente). De todas formas, existen alternativas a esto, a menos que cada vez que quieras leer un contrato verifiques el código fuente con tu propio nodo.

En fin, volvamos al código de DAI en Solidity.

En el contrato inteligente de DAI (compilado con Solidity v.0.5.12, enfoquémonos en la función a ejecutar: transfer.

function transfer(address dst, uint wad) external returns (bool) {
    return transferFrom(msg.sender, dst, wad);
}

Cuando se ejecuta transfer, esta función va a llamar a otra función denominada transferFrom, para luego devolver cualquier bandera booleana que retorne transferFrom. El primer y el segundo argumento de transfer (acá llamados dst y wad) se pasan directamente a transferFrom. Esta función, además, lee la dirección del emisor (disponible como una variable global en Solidity en msg.sender).

En nuestro caso, los siguientes serían los valores que se pasan a transferFrom:

return transferFrom(
    msg.sender, // 0x6fC27A75d76d8563840691DDE7a947d7f3F179ba (mi dirección en el nodo de prueba local)
    dst,        // 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 (la dirección de Vitalik)
    wad         // 1000000000000000000 (1 DAI en wei)
);

Ahora veamos la función transferFrom.

function transferFrom(address src, address dst, uint wad) public returns (bool) {
    ...
}

Primero, se comprueba el balance del emisor en relación con la cantidad que se está transfiriendo.

require(balanceOf[src] >= wad, "Dai/insufficient-balance");

Es simple: no podés transferir más DAI del que tenés en tu balance. Si no tuviese 1 DAI, la ejecución se habría detenido en este momento, devolviendo un error con un mensaje. Fijate que el balance de cada dirección se lleva en el almacenamiento interno del contrato inteligente (conocido como storage). En una estructura de datos mapping denominada balanceOf. Si tenés por lo menos 1 DAI, te puedo asegurar que la dirección de tu cuenta tiene una entrada en alguna posición de ese registro.

Segundo, se validan los permisos de transferencia de tokens (allowances en inglés).

// no te preocupes mucho por esto :)
if (src != msg.sender && allowance[src][msg.sender] != uint(-1)) {
    require(allowance[src][msg.sender] >= wad, "Dai/insufficient-allowance");
    allowance[src][msg.sender] = sub(allowance[src][msg.sender], wad);
}

Esto no nos interesa ahora. Porque no estamos ejecutando la transferencia en nombre de otra cuenta. De todos modos, fijate que ese es un mecanismo que todos los tokens ERC20 deben implementar, y DAI no es la excepción. Sirve para permitirle a otras cuentas transferir tokens desde la tuya.

Tercero, ocurre el tan ansiado intercambio entre los balances.

balanceOf[src] = sub(balanceOf[src], wad); balanceOf[dst] = add(balanceOf[dst], wad);

Cuando enviás 1 DAI, el balance del emisor disminuye 1000000000000000000 y el balance del receptor aumenta 1000000000000000000. Estas operaciones se hacen leyendo y escribiendo en la estructura de datos balanceOf. Vale la pena destacar el uso de dos funciones especiales add (sumar) y sub (restar) para hacer las cuentas.

¿Por qué no simplemente usar los operadores + y - ?

Si fuera tan simple! Este contrato se compiló con Solidity 0.5.12. En esa época, el compilador no incluía verificaciones automáticas overflows y underflows como lo hace hoy en día. Por lo tanto, los desarrolladores tenían que acordarse (o ser amigablemente recordados por auditores 😛) de implementarlas por su cuenta donde sea necesario. Por esto es que se usan add y sub en el contrato de DAI. Son funciones internas bastante simples que realizan sumas y restas con verificaciones para evitar problemas aritméticos.

function add(uint x, uint y) internal pure returns (uint z) {
    require((z = x + y) >= x);
}

function sub(uint x, uint y) internal pure returns (uint z) {
    require((z = x - y) <= x);
}

La función add suma x e y, y detiene la ejecución si el resultado de la operación es menor que x (lo cual evita overflows en la variable de tipo entero sin signo).

La función sub resta y de x, y detiene la ejecución si el resultado de la operación es mayor que x (lo cual evita underflows en la variable de tipo entero sin signo).

Cuarto, se emite un evento Transfer (como sugieren las especificaciones del estándar ERC20).

emit Transfer(src, dst, wad);

Un evento es una operación de logging. Los datos emitidos en un evento se pueden recuperar solo a través de servicios externos a la blockchain. Nunca desde otros contratos dentro de la blockchain.

Para nuestra transferencia, el evento emitido registraría tres elementos. La dirección del emisor (0x6fC27A75d76d8563840691DDE7a947d7f3F179ba); la dirección del receptor (0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045) y la cantidad enviada (1000000000000000000).

Los primeros dos corresponden a los parámetros etiquetados como indexed (indexados) en la declaración del evento. Los parámetros indexados facilitan la recuperación de los datos, ya que permiten filtrar búsquedas por cualquiera de los valores emitidos. A menos que el evento se etiquete como anonymous (anónimo), el identificador del evento también se incluye como un tópico del evento (conocido como topic).

Por ende, para ser más específico, el evento Transfer con el que estamos lidiando registra, 3 tópicos (el identificador del evento, la dirección del emisor, y la dirección del receptor) y 1 valor (la cantidad de DAI transferida). Veremos más detalles sobre este evento una vez que lleguemos a cositas de bajo nivel de la EVM.

Al final de la función, se devuelve el valor booleano true (verdadero) (como sugieren las especificaciones del estándar ERC20).

return true;

Esa es la manera de señalizar que la transferencia se ejecutó exitosamente. Esta bandera booleana se pasa a la función externa transfer que inició la llamada (la cual simplemente también la devuelve).

¡Ya está! Si alguna vez enviaste DAI, te aseguro que esa es la lógica que ejecutaste. Ese es el trabajo por el cual le pagaste a una red global de nodos descentralizada para que lo haga por vos.

No, pará. Puede que se me haya ido un poco la mano. Es un poquito mentira eso. Porque, como te dije antes, la EVM no entiende Solidity. Los nodos no ejecutan Solidity. Ejecutan código de la EVM.

Llegó el momento.

Ejecución en la EVM

Me voy a poner aún más técnico en esta sección.

Voy a asumir que tenés alguna minima experiencia de mirar código de bajo nivel de la EVM. Si no te resulta familiar, te recomiendo mucho que primero leas esta serie de artículos o esta, que es más nueva. Ahí vas a encontrar muchos de los conceptos de esta sección, explicados uno por uno y en mayor profundidad.

Las instrucciones de bajo nivel son bastante difícil de mirar; ya tuvimos una pequeña dosis en una sección previa. Una manera más sana para leerlo es usando una versión desensamblada del código de maquina. Podés encontrarla acá (lo extraje en este gist para que sea más fácil consultarlo y linkearlo a lo largo de esta sección).

Puntero de memoria libre y valor de la llamada

Las primeras tres instrucciones no deberían sorprenderte si ya sos amigo del compilador de Solidity. Se trata de inicializar el puntero de memoria libre.

0x0: PUSH1     0x80
0x2: PUSH1     0x40
0x4: MSTORE

El compilador de Solidity reserva las posiciones de memoria de 0x00 a 0x80 para cuestiones internas. Entonces, el «puntero de memoria libre» es un puntero a la primera posición de memoria que se puede usar libremente. Está almacenado en 0x40 y su inicialización apunta a la posición 0x80.

Antes de seguir, tené en cuenta que todos los códigos de operación de la EVM que veamos acá tienen una implementación equivalente en Geth. Por ejemplo, podés ver realmente cómo la implementación de MSTORE saca dos elementos del stack y le escribe a la memoria de EVM una palabra de 32 bytes:

func opMstore(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
    mStart, val := scope.Stack.pop(), scope.Stack.pop()
    scope.Memory.Set32(mStart.Uint64(), &val)
    return nil, nil
}

Las siguientes instrucciones aseguran que la llamada no tenga ningún valor de ETH. Si lo tuviese, la ejecución se detendría en la instrucción REVERT. Observá el uso de la instrucción CALLVALUE (implementada acá) para leer el valor actual de ETH de la llamada.

0x5: CALLVALUE 
0x6: DUP1      
0x7: ISZERO    
0x8: PUSH2     0x10
0xb: JUMPI     
0xc: PUSH1     0x0
0xe: DUP1      
0xf: REVERT

Nuestra llamada no tiene ningún valor (el campo value de la transacción se estableció en cero), así que podemos continuar sin ningún problema.

Validación de los datos de la llamada (primera parte)

El compilador introduce otra verificación. Esta vez, para determinar si el tamaño de los datos de la llamada, o calldata, (que se obtuvieron con la instrucción CALLDATASIZE, implementada acá) es menor que 4 bytes (¿ves la 0x4 y la instrucción LT acá abajo?). En ese caso, saltaría a la posición 0x142, lo cual detendría la ejecución en la instrucción REVERT, en la posición 0x146.

0x10: JUMPDEST
0x11: POP       
0x12: PUSH1     0x4
0x14: CALLDATASIZE
0x15: LT        
0x16: PUSH2     0x142
0x19: JUMPI

...

0x142: JUMPDEST  
0x143: PUSH1     0x0
0x145: DUP1      
0x146: REVERT

Eso nos dice que el tamaño de los datos de la llamada al contrato inteligente de DAI debe ser, obligatoriamente, por lo menos 4 bytes. Se debe a que el mecanismo de codificación por ABI que usa Solidity identifica funciones con los primeros cuatro bytes del hash keccak256 de la firma. A estos 4 bytes se los conoce como selector de función. Leé las especificaciones.

Si los datos de la llamada no tuviesen por lo menos 4 bytes, no sería posible identificar la función. Entonces, como vimos recién, el compilador introdujo las instrucciones necesarias de la EVM para que falle con anticipación en ese escenario.

Para llamar la función transfer(address, uint256), los primeros cuatro bytes de los datos de la llamada deben coincidir con el selector de la función. Son los que muestro a continuación:

$ cast sig "transfer(address,uint256)"
0xa9059cbb

Así es. Son exactamente los mismos primeros 4 bytes del campo data de la transacción que construimos antes:

0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000

Validada la longitud de los datos de la llamada, es hora de usarlos. Observá a continuación cómo los primeros 4 bytes de los datos de la llamada se colocan arriba del stack (la instrucción de la EVM en la que tenés que enfocarte acá es CALLDATALOAD, que se esta implementada acá).

0x1a: PUSH1     0x0
0x1c: CALLDATALOAD
0x1d: PUSH1     0xe0
0x1f: SHR

En realidad, CALLDATALOAD envía 32 bytes de los datos de la llamada al stack y, para quedarse únicamente con los primeros 4 bytes, se tiene que recortar con la instrucción SHR.

Elección de funciones

No trates de entender lo que sigue renglón por renglón. En cambio, prestale atención al patrón de alto nivel que surge. Meto algunas líneas separadoras para que lo puedas entender mejor:

0x20: DUP1
0x21: PUSH4     0x7ecebe00
0x26: GT        
0x27: PUSH2     0xb8
0x2a: JUMPI
0x2b: DUP1      
0x2c: PUSH4     0xa9059cbb
0x31: GT        
0x32: PUSH2     0x7c
0x35: JUMPI
0x36: DUP1      
0x37: PUSH4     0xa9059cbb
0x3c: EQ        
0x3d: PUSH2     0x6b4
0x40: JUMPI
0x41: DUP1      
0x42: PUSH4     0xb753a98c
0x47: EQ        
0x48: PUSH2     0x71a
0x4b: JUMPI

No es coincidencia que algunos de los valores hexadecimales que se envían al stack tengan 4 bytes de largo. Esos son, de hecho, selectores de funciones.

El set de instrucciones de arriba es una estructura bastante usual que genera el compilador de Solidity. Se conoce comúnmente por su nombre en ingles: function dispatcher. Algo así como el «despachador» o «elector» de funciones. Se parece a una sentencia «if-else» o un «switch». Su propósito es elegir qué función ejecutar. Lo hace comparando los primeros 4 bytes de los datos de la llamada con el conjunto de selectores conocidos de las funciones del contrato. Una vez que encuentra una coincidencia, la ejecución salta a otra sección, donde se ubican las instrucciones para esa función en particular.

Siguiendo la lógica de arriba, la EVM compara los primeros 4 bytes de los datos de la llamada con el selector de la función transfer: 0xa9059cbb. Y salta a la posición 0x6b4.

Es así como se le indica a la EVM que debe iniciar la ejecución de la transferencia de DAI.

Validación de los datos de la llamada (segunda parte)

Antes de continuar, la EVM debe acordarse desde que posición debe seguir ejecutando código una vez que toda la lógica relacionada con la función se haya ejecutado.

La forma de hacerlo consiste en mantener la posición adecuada en el stack. Chequeá el valor 0x700 que se pone a continuación. Va a quedar en el stack hasta que en algún momento (un poco más adelante) se recupere y se use para volver hacia atrás y completar la ejecución.

0x6b4: JUMPDEST  
0x6b5: PUSH2     0x700

Vamos a la función transfer.

El compilador inserta un poco de lógica con el fin de asegurar que el tamaño de los datos de la llamada sea correcto para una función con dos parámetros del tipo address y uint256. Para la función transfer son 68 bytes (4 bytes del selector + 64 bytes de los dos parámetros codificados por ABI).

0x6b8: PUSH1     0x4
0x6ba: DUP1      
0x6bb: CALLDATASIZE
0x6bc: SUB       
0x6bd: PUSH1     0x40
0x6bf: DUP2      
0x6c0: LT        
0x6c1: ISZERO    
0x6c2: PUSH2     0x6ca
0x6c5: JUMPI     
0x6c6: PUSH1     0x0
0x6c8: DUP1      
0x6c9: REVERT

Si el tamaño de los datos de la llamada fuese más chico, la ejecución se detendría en REVERT, en la posición 0x6c9. Ya que los datos de la llamada de nuestra transacción se codificaron correctamente y, por lo tanto, tienen la longitud que corresponde, la ejecución salta a la posición 0x6ca.

Leyendo parámetros

El siguiente paso consiste en leer los dos parámetros proporcionados en los datos de la llamada. Específicamente, la dirección de 20 bytes 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 y el número 1000000000000000000 (0x0de0b6b3a7640000 en hexadecimal). Ambos se codificaron por ABI, en fragmentos de 32 bytes. Por lo tanto, es necesario realizar alguna manipulación para leer los valores adecuados y situarlos en el stack.

0x6ca: JUMPDEST  
0x6cb: DUP2      
0x6cc: ADD       
0x6cd: SWAP1     
0x6ce: DUP1      
0x6cf: DUP1      
0x6d0: CALLDATALOAD
0x6d1: PUSH20    0xffffffffffffffffffffffffffffffffffffffff
0x6e6: AND       
0x6e7: SWAP1     
0x6e8: PUSH1     0x20
0x6ea: ADD       
0x6eb: SWAP1     
0x6ec: SWAP3     
0x6ed: SWAP2     
0x6ee: SWAP1     
0x6ef: DUP1      
0x6f0: CALLDATALOAD
0x6f1: SWAP1     
0x6f2: PUSH1     0x20
0x6f4: ADD       
0x6f5: SWAP1     
0x6f6: SWAP3     
0x6f7: SWAP2     
0x6f8: SWAP1     
0x6f9: POP       
0x6fa: POP       
0x6fb: POP       
0x6fc: PUSH2     0x1df4
0x6ff: JUMP

Para explicarlo de una forma más visual, luego de aplicar de forma consecutiva la serie de instrucciones mencionadas arriba (hasta la posición 0x6gb), la cima del stack se ve así:

0x0000000000000000000000000000000000000000000000000de0b6b3a7640000
0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045

Y así es como la EVM extrae ambos argumentos de los datos de la llamada y los ubica en el stack.

Las últimas dos instrucciones de arriba (las posiciones 0x6fc y 0x6ff) hacen que la ejecución salte a la posición 0x1df4.

La función transfer

Durante el breve análisis de Solidity, vimos que la función transfer(address,uint256) es una capa ligera sobre la función más compleja transferForm(address,address,uint256). El compilador traduce esta llamada interna de una función a otra en las siguientes instrucciones de la EVM:

0x1df4: JUMPDEST  
0x1df5: PUSH1     0x0
0x1df7: PUSH2     0x1e01
0x1dfa: CALLER    
0x1dfb: DUP5      
0x1dfc: DUP5      
0x1dfd: PUSH2     0xa25
0x1e00: JUMP

Primero mirá la instrucción PUSH2 colocando el valor 0x1e01. Así es como se le indica a la EVM que debe «recordar» la posición exacta a la que debe volver para seguir con la ejecución, luego de la llamada interna que se viene.

Luego, prestale atención al uso de CALLER (porque en Solidity la llamada interna usa msg.sender). Así como también a las dos instrucciones DUP5. Juntas, ponen en la parte superior del stack los tres argumentos necesarios para transferFrom: la dirección de quien origina la llamada, la dirección de quien la recibe y la cantidad a transferir. Las dos últimas ya estaban en algún lugar del stack, es por eso que se emplea DUP5. La parte superior del stack ahora dispone de todos los argumentos necesarios:

0x0000000000000000000000000000000000000000000000000de0b6b3a7640000
0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045
0x0000000000000000000000006fc27a75d76d8563840691dde7a947d7f3f179ba

Por último, según las instrucciones 0x1dfd y 0x1e00, la ejecución salta a la posición 0xa25.

En este momento, la EVM empieza a ejecutar las instrucciones correspondientes a la función transferFrom.

La función transferFrom

Lo primero a chequear es si la cuenta de origen tiene suficiente DAI en su balance. De lo contrario, debe revertirse la llamada. El balance del emisor se mantiene en el almacenamiento interno del contrato. Por lo que la instrucción fundamental de la EVM que se necesita es SLOAD. Sin embargo, SLOAD tiene que saber qué posición del almacenamiento interno se tiene que leer. Para el tipo de datos mapping (el tipo de estructura de datos de Solidity que contiene los balances de las cuentas en el contrato inteligente de DAI), eso no es tan sencillo de definir.

No voy a ahondar en el formato que tienen las variables de estado de Solidity en el almacenamiento del contrato. Podés leer más sobre eso acá para la v.0.5.15.

Basta con decir que dada la dirección de la clave k para el mapping balanceOf, su valor correspondiente uint256 se mantendrá en la posición de almacenamiento keccak256(k . p), donde p representa la posición en el almacenamiento del mapping en sí, y ., la concatenación. Te dejo a vos hacer las cuentas.

Para simplificarlo, destaquemos un par de operaciones a realizar. La EVM tiene que i) calcular la posición de almacenamiento para el mapping, ii) leer el valor, iii) compararla con la cantidad que se va a transferir (un valor que ya está en el stack). Por lo tanto, deberíamos poder ver instrucciones como SHA3 para el hashing, SLOAD para leer el almacenamiento, y LT para hacer la comparación.

0xa25: JUMPDEST  
0xa26: PUSH1     0x0
0xa28: DUP2      
0xa29: PUSH1     0x2
0xa2b: PUSH1     0x0
0xa2d: DUP7      
0xa2e: PUSH20    0xffffffffffffffffffffffffffffffffffffffff
0xa43: AND       
0xa44: PUSH20    0xffffffffffffffffffffffffffffffffffffffff
0xa59: AND       
0xa5a: DUP2      
0xa5b: MSTORE    
0xa5c: PUSH1     0x20
0xa5e: ADD       
0xa5f: SWAP1     
0xa60: DUP2      
0xa61: MSTORE    
0xa62: PUSH1     0x20
0xa64: ADD       
0xa65: PUSH1     0x0
0xa67: SHA3      --> calculando posición en almacenamiento
0xa68: SLOAD     --> leyendo el almacenamiento
0xa69: LT        --> comparando el balance contra la cantidad a transferir
0xa6a: ISZERO    
0xa6b: PUSH2     0xadc
0xa6e: JUMPI

Si el emisor no tenía suficiente DAI, la ejecución va a seguir en 0xa6f y alcanzará REVERT en 0xadb. Como no me olvidé de acreditar 1 DAI en el balance de mi cuenta emisora previo a hacer todo esto, podemos seguir en la posición 0xadc.

El siguiente conjunto de instrucciones corresponde la EVM chequeando si quien origina la llamada coincide con la dirección del emisor (acordate del segmento de código if (src != msg.sender \...) { \... } en el contrato).

0xadc: JUMPDEST  
0xadd: CALLER    
0xade: PUSH20    0xffffffffffffffffffffffffffffffffffffffff
0xaf3: AND       
0xaf4: DUP5      
0xaf5: PUSH20    0xffffffffffffffffffffffffffffffffffffffff
0xb0a: AND       
0xb0b: EQ        
0xb0c: ISZERO    
0xb0d: DUP1      
0xb0e: ISZERO    
0xb0f: PUSH2     0xbb4
0xb12: JUMPI
...
0xbb4: JUMPDEST  
0xbb5: ISZERO    
0xbb6: PUSH2     0xdb2
0xbb9: JUMPI

Ya que no coinciden, la ejecución continúa en la posición 0xdb2.

¿El código de abajo no te hace acordar a algo? Revisá las instrucciones que se usan. No te enfoques en ellas por separado. Usa tu intuición para detectar patrones de alto nivel y las instrucciones más relevantes.

0xdb2: JUMPDEST  
0xdb3: PUSH2     0xdfb
0xdb6: PUSH1     0x2
0xdb8: PUSH1     0x0
0xdba: DUP7      
0xdbb: PUSH20    0xffffffffffffffffffffffffffffffffffffffff
0xdd0: AND       
0xdd1: PUSH20    0xffffffffffffffffffffffffffffffffffffffff
0xde6: AND       
0xde7: DUP2      
0xde8: MSTORE    
0xde9: PUSH1     0x20
0xdeb: ADD       
0xdec: SWAP1     
0xded: DUP2      
0xdee: MSTORE    
0xdef: PUSH1     0x20
0xdf1: ADD       
0xdf2: PUSH1     0x0
0xdf4: SHA3      
0xdf5: SLOAD     
0xdf6: DUP4      
0xdf7: PUSH2     0x1e77
0xdfa: JUMP

Si se parece a leer un mapping del almacenamiento interno, ¡es porque es eso! Eso de arriba es la EVM leyendo el balance del emisor desde el mapping balanceOf.

La ejecución luego salta a la posición 0x1e77, donde se ubica el cuerpo de la función sub.

La función sub resta dos números y revierte en caso de underflow. No incluyo el set de instrucciones que la componen, aunque podés seguirlo acá. El resultado de la operación aritmética se mantiene en el stack.

Volvamos a las instrucciones correspondientes al cuerpo de la función transferFrom; ahora, el resultado de la resta debe escribirse en el almacenamiento y actualizar el mapping balanceOf.

Tratá de observar abajo el cálculo realizado para obtener la posición de almacenamiento que corresponde a la entrada del mapping, la cual conduce a la ejecución de la instrucción SSTORE. Esta instrucción es la que, efectivamente, escribe los datos en el estado. Es decir, es la que actualiza el almacenamiento del contrato.

0xdfb: JUMPDEST  
0xdfc: PUSH1     0x2
0xdfe: PUSH1     0x0
0xe00: DUP7      
0xe01: PUSH20    0xffffffffffffffffffffffffffffffffffffffff
0xe16: AND       
0xe17: PUSH20    0xffffffffffffffffffffffffffffffffffffffff
0xe2c: AND       
0xe2d: DUP2      
0xe2e: MSTORE    
0xe2f: PUSH1     0x20
0xe31: ADD       
0xe32: SWAP1     
0xe33: DUP2      
0xe34: MSTORE    
0xe35: PUSH1     0x20
0xe37: ADD       
0xe38: PUSH1     0x0
0xe3a: SHA3      
0xe3b: DUP2      
0xe3c: SWAP1     
0xe3d: SSTORE

Después se ejecuta un set de instrucciones bastante similar con el fin de actualizar el balance de la cuenta receptora. Primero, se lee desde el mapping balanceOf en el almacenamiento. Luego, el balance se suma a la cantidad que se va a transferir mediante la función add. Por último, el resultado se escribe en la posición de almacenamiento que corresponde.

Registrando eventos

En el código Solidity, el evento Transfer se emitió una vez que se actualizaron los balances. Por lo que tiene que haber un conjunto de instrucciones de la EVM que se ocupe de emitir dichos eventos con los datos adecuados.

Sin embargo, los eventos pertenecen a el mundo de fantasía de Solidity. En el mundo de la EVM, los eventos corresponden a las operaciones de logging.

Las operaciones de logging se llevan a cabo con el conjunto de instrucciones LOG. Hay un par de variantes en función de la cantidad de tópicos que se vayan a guardar. En el caso de DAI, ya vimos que el evento emitido Transfer tiene 3 tópicos.

No es de extrañar, entonces, que encontremos un conjunto de instrucciones que nos lleve a la ejecución de la instrucción LOG3.

0xeca: POP       
0xecb: DUP3      
0xecc: PUSH20    0xffffffffffffffffffffffffffffffffffffffff
0xee1: AND 
0xee2: DUP5
0xee3: PUSH20    0xffffffffffffffffffffffffffffffffffffffff
0xef8: AND       
0xef9: PUSH32    0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
0xf1a: DUP5      
0xf1b: PUSH1     0x40
0xf1d: MLOAD     
0xf1e: DUP1      
0xf1f: DUP3      
0xf20: DUP2      
0xf21: MSTORE    
0xf22: PUSH1     0x20
0xf24: ADD       
0xf25: SWAP2     
0xf26: POP       
0xf27: POP       
0xf28: PUSH1     0x40
0xf2a: MLOAD     
0xf2b: DUP1      
0xf2c: SWAP2     
0xf2d: SUB       
0xf2e: SWAP1     
0xf2f: LOG3

Hay al menos un valor que se destaca en esas instrucciones: 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef.

Ese es el principal identificador del evento. También conocido como tópico 0. Es un valor estático que el compilador inserta en el momento de compilación. No es más que el hash de la firma del evento:

$ cast keccak "Transfer(address,address,uint256)"
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef

Así se va a ver el stack justo antes de alcanzar la instrucción LOG3:

0x0000000000000000000000000000000000000000000000000000000000000080
0x0000000000000000000000000000000000000000000000000000000000000020
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef -- tópico 0 (identificador del evento)
0x0000000000000000000000006fc27a75d76d8563840691dde7a947d7f3F179ba -- topic 1 (dirección de origen)
0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045 -- topic 2 (dirección de destino)

¿Dónde está la cantidad a transferir? ¡En la memoria! Antes de ejecutar LOG3, se le indicó a la EVM que debe almacenar la cantidad en su memoria, para que luego la instrucción de logging la pueda procesar. Si te fijas en la posición 0xf21, vas a ver que la instrucción MSTORE es la responsable de esta tarea.

Por lo tanto, una vez que se haya alcanzado LOG3, la EVM va a poder tomar sin problema el valor guardado en la memoria. Comenzado en posición 0x80 y leyendo 0x20 bytes (los dos primeros elementos del stack).

Otra forma para entender cómo funcionan las operaciones de logging de la EVM es leer su implementación en Geth. Ahí vas a encontrar una única función encargada de gestionar todas las instrucciones de logging. Vas a ver cómo i) se inicializa un conjunto de tópicos, ii) se lee la posición de la memoria y el tamaño de los datos desde el stack, iii) se leen los tópicos del stack y se insertan en el array, iv) se lee el valor desde la memoria, v) se adjunta el log, que contiene la dirección donde se emitió, los tópicos y el valor.

Ya vamos a llegar a la forma en que se recuperan esos datos.

Retornos

Lo último que tiene que hacer la función transferFrom es devolver el valor booleano true. Es por eso que, la primera instrucción luego de LOG3, envía el valor 0x1 al stack.

0xf30: PUSH1    0x1

Las siguientes instrucciones preparan el stack para salir de la función transferFrom y volver a transfer. Recordemos que la posición para este salto ya se había almacenado en el stack, por eso no la ves acá abajo.

0xf32: SWAP1     
0xf33: POP       
0xf34: SWAP4     
0xf35: SWAP3     
0xf36: POP       
0xf37: POP       
0xf38: POP       
0xf39: JUMP

Ya devuelta en la función transfer, hay que preparar el stack para el salto final, hacia la posición donde se va a completar la ejecución. La posición para este próximo salto también se había almacenado en el stack (¿te acordás del valor 0x700 que se había colocado en el stack algunas secciones atrás?).

0x1e01: JUMPDEST  
0x1e02: SWAP1     
0x1e03: POP       
0x1e04: SWAP3     
0x1e05: SWAP2     
0x1e06: POP       
0x1e07: POP       
0x1e08: JUMP

Lo único que resta es preparar el stack para la instrucción final: RETURN. Esta instrucción se encarga de leer algunos datos de la memoria y devolverlos al emisor original.

Para la transferencia de DAI, los datos obtenidos incluirían, simplemente, la bandera booleana true que devolvió la función transfer. El valor ya está situado en el stack.

La EVM comienza por tomar la primera posición de memoria que esté libre. Esto se hace leyendo el puntero de memoria libre:

0x700: JUMPDEST  
0x701: PUSH1     0x40
0x703: MLOAD

A continuación, el valor tiene que almacenarse en memoria con MSTORE. Aunque no es tan fácil distinguirlas, las instrucciones de abajo son las que el compilador considera más adecuadas para preparar el stack y llegar a la operación MSTORE.

0x704: DUP1      
0x705: DUP3      
0x706: ISZERO    
0x707: ISZERO    
0x708: ISZERO    
0x709: ISZERO    
0x70a: DUP2      
0x70b: MSTORE

La instrucción RETURN lee los datos que necesita desde la memoria. Entonces, algo tiene que decirle cuánta memoria leer y dónde empezar. Las instrucciones de abajo simplemente le indican a la EVM que lea y devuelva 0x20 bytes de memoria, empezando en el puntero de memoria libre.

0x70c: PUSH1     0x20
0x70e: ADD       
0x70f: SWAP2     
0x710: POP       
0x711: POP       
0x712: PUSH1     0x40
0x714: MLOAD     
0x715: DUP1      
0x716: SWAP2     
0x717: SUB       
0x718: SWAP1     
0x719: RETURN

Se devuelve el valor 0x0000000000000000000000000000000000000000000000000000000000000001 (correspondiente al valor booleano true).

La ejecución se detiene.

El intérprete (segunda parte)

La ejecución finalizó. El intérprete debe parar de iterar. En Geth, eso se hace así:

// loop de ejecución del intérprete
for { 
    ...
    // ejecutar la operación
    res, err = operation.execute(&pc, in, callContext)
    if err != nil {
        break
    }
    ...
}

Eso significa que, de alguna manera, la implementación del código de operación RETURN debe devolver un error. Incluso para ejecuciones exitosas como la nuestra. De hecho, lo hace. Aunque lo cierto es que en este caso actúa como una bandera. El error se elimina cuando coincide con la bandera que devuelve la ejecución exitosa del código de operación RETURN.

Pagos y reembolsos de gas

Una vez que finalizó la ejecución del intérprete, estamos de nuevo en la llamada que originalmente activó al intérprete. La ejecución se completó correctamente. Así, los datos obtenidos y cualquier gas restante simplemente se devuelven].

La llamada también finaliza. La ejecución sigue para completar la transición de estados.

Primero, otorga reembolsos de gas. Estos se añaden a cualquier resto de gas en la transacción. La cantidad que se reembolsa está limitada a 1/5 del gas utilizado (debido a EIP 3529). Todo el gas disponible ahora (el que quedaba más el que se reembolsa) se paga en ETH a la cuenta del emisor, con la tarifa que el emisor estableció originalmente en la transacción. Todo el gas restante se añade nuevamente al gas disponible en el bloque, para que las transacciones posteriores puedan consumirlo.

Luego, se paga a la dirección de coinbase (la dirección del minero en Proof of Work, la dirección del validador en Proof of Stake) lo que se prometió en un principio: la propina (tip en inglés). Es interesante que el pago se realiza por el gas que se usa durante la ejecución. Incluso si después se reembolsó una parte de gas. Además, fijate acá cómo se calcula la propina efectiva. No te fijés solamente que está limitada por el campo maxPriorityFeePerGas, sino, lo que es más importante, ¡fijate que no incluye la tarifa base! No es ningún error, a Ethereum le gusta ver cómo se quema el ETH.

Por último, el resultado de la ejecución se agrupa en una estructura más bonita, que incluye el gas que se usó, cualquier error de la EVM que pudo haber anulado la ejecución (en nuestro caso, ninguno), junto con los datos retornados de la EVM.

Creación del recibo de la transacción

La estructura que representa los resultados de la ejecución ahora se pasa de nuevo hacia arriba. En este punto Geth hace algunas tareas de limpieza interna en el estado de la ejecución. Finalizada, acumula el gas que se usó en la transacción. Reembolsos incluidos.

Ahora se crea el recibo de la transacción (receipt en inglés). El recibo es un objeto que resume los datos relacionados con la ejecución de la transacción. Incluye información como el estado de la ejecución (exitoso/fallido), el hash de la transacción, las unidades de gas que se usaron, la dirección del contrato que se creó (en nuestro caso, ninguna), los logs emitidos, el filtro bloom de la transacción y más.

Pronto vamos a recuperar todos los contenidos del recibo de nuestra transacción.

Si querés investigar más a fondo sobre los registros de la transacción y el rol del filtro bloom, mira el artículo de noxx.

Sellado del bloque

La ejecución de las transacciones posteriores continúa hasta que el bloque se queda sin espacio.

Es ahí cuando el nodo llama al motor de consenso para finalizar el bloque. En Proof of Work, eso implica acumular las recompensas por minar bloques (al emitir recompensas completas en ETH a la dirección de coinbase, junto con recompensas parciales para los bloques ommer) y actualizar la raíz del estado final del bloque.

A continuación, se ensambla el bloque, poniendo todos los datos en el lugar correcto. Esto incluye información como el hash de transacciones del encabezado, o el hash de recibos.

Todo listo para el minado de Proof of Work. Se crea una «tarea» nueva y se envía hacia el listener correcto. La tarea de sellado, delegada al motor de consenso, comienza.

No voy a explicar en detalle cómo se hace el minado por Proof of Work. Ya hay un montón de información sobre eso en internet. Solamente fijate que en Geth esto implica un proceso de prueba y error multihilo para encontrar un número que satisfaga una condición necesaria. Obviamente, una vez que Ethereum se cambie a Proof of Stake, el proceso de sellado se va a manejar de una manera bastante diferente.

El bloque minado se envía al canal apropiado y se recibe en el loop de resultados, donde los recibos y los logs se actualizan con los datos del último bloque, luego de que se haya minado.

El bloque finalmente se escribe en la cadena, colocándolo en su punta.

Transmisión del bloque

El siguiente paso consiste en anunciar a toda la red que se minó un nuevo bloque. Mientras tanto, el bloque en sí se almacena en un conjunto de pendientes, que espera pacientemente las confirmaciones de otros nodos.

El anuncio se hace al publicar un evento específico, recogido por el loop de transmisión de minados. Ahí el bloque se propaga por completo a un subconjunto de pares y el resto recibe una versión mas liviana.

Más concretamente, la propagación implica enviar datos del bloque a la raíz cuadrada de los pares conectados. Internamente, esta se implementa al enviar los datos al canal de bloques en cola, hasta que se envían a través de la capa peer-to-peer. El mensaje entre pares se identifica como NewBlockMsg. El resto recibe un simple anuncio que incluye el hash del bloque.

Nota que este proceso es válido únicamente para Proof of Work. La propagación de bloques se va a realizar en los motores de consenso en Proof of Stake.

Verificación del bloque

Los nodos están continuamente a la escucha de mensajes. Cada tipo de mensaje posible tiene un manipulador (handler en inglés) que se invoca apenas se recibe el mensaje correspondiente.

Como consecuencia, al recibir el mensaje NewBlockMsg con los datos del bloque, se ejecuta su handler correspondiente. El handler decodifica el mensaje y ejecuta algunas validaciones por adelantado en el bloque propagado. Entre ellas, se incluyen verificaciones de estado preliminares en los datos del encabezado. Más que nada para asegurar que estén completos y bien limitados. También se incluyen validaciones para el bloque tío y los hashes de transacciones.

Luego, el par que envía el mensaje se marca como propietario del bloque, lo cual evita que luego el bloque se propague de vuelta a él.

Por último, el paquete se pasa a un segundo handler, donde el bloque se va a colocar en una cola para ser importado a una copia local de la cadena. Para colocar el bloque en la cola, se envía una solicitud de importación directa al canal correspondiente. Cuando se recibe la solicitud, esta dispara la operación real para ponerlo en cola y, finalmente, envía los datos del bloque a la cola.

El bloque ahora se encuentra en la cola local, listo para ser procesado. La cola se lee periódicamente en el loop principal de la obtención de bloques del nodo. Cuando el bloque consigue colocarse primero, el nodo lo seleccionará y va a tratar de importarlo.

Hay por lo menos dos validaciones que vale la pena destacar antes de la verdadera inserción del bloque candidato.

Primero, la blockchain local ya debe incluir al padre del bloque propagado.

Segundo, el encabezado del bloque debe ser válido. Estas validaciones son las que realmente cuentan. Quiero decir, las que son fundamentales para el consenso de la red y que están especificadas en el Yellow Paper de Ethereum. Por ende, las maneja el motor de consenso.

A modo de ejemplo, el motor verifica que la prueba de trabajo del bloque sea válida; o que el timestamp del bloque no esté en el pasado ni muy adelantado en el futuro, o que el número del bloque se haya incrementado correctamente, entre otras cosas.

Una vez que se haya verificado que el encabezado sigue las reglas de consenso, el bloque entero se propaga a un subgrupo de pares. Sólo entonces se importa el bloque.

Ocurren un montón de cosas durante una importación. Así que voy a ir directo al grano.

Después de varias validaciones adicionales, se recupera el estado del bloque padre. Este es el estado sobre el que se va a ejecutar la primera transacción del bloque nuevo. Usándolo como punto de referencia, se procesa todo el bloque.

Si alguna vez escuchaste que todos los nodos de Ethereum deben ejecutar y validar cada una de las transacciones, ahora podés estar seguro de que es así. Más adelante, se valida el estado posterior (mirá cómo se hace acá). Finalmente, el bloque se escribe en la cadena local.

La importación exitosa permite anunciar (sin transmitirlo por completo) el bloque al resto de los pares del nodo.

El proceso de verificación completo se replica a través de todos los nodos que reciben el bloque. Una gran cantidad lo va a aceptar en sus blockchains locales, y después van a llegar bloques más nuevos para insertarse por encima de este.

Recuperando la transacción

Después de que se hayan minado algunos bloques sobre el que incluye nuestra transacción, uno ya puede asumir con cierto grado de seguridad que la transacción se confirmó.

Recuperar la transacción de la blockchain es simple. Todo lo que necesitamos es su hash. Qué bueno que lo obtuvimos ni bien enviamos la transacción.

Los datos de la transacción propiamente dicha, más el hash y número del bloque, siempre pueden recuperarse en el endpoint eth_getTransactionByHash del nodo. Como es de esperar, ahora devuelve esto:

{
    "hash": "0xbf77c4a9590389b0189494aeb2b2d68dc5926a5e20430fb5bc3c610b59db3fb5",
    "type": 2,
    "accessList": [],
    "blockHash": "0xe880ba015faa9aeead0c41e26c6a62ba4363822ddebde6dd77a759a753ad2db2",
    "blockNumber": 15166167,
    "transactionIndex": 0,
    "confirmations": 6,
    "from": "0x6fC27A75d76d8563840691DDE7a947d7f3F179ba",
    "maxPriorityFeePerGas": 2000000000,
    "maxFeePerGas": 120000000000,
    "gasLimit": 40000,
    "to": "0x6B175474E89094C44Da98b954EedeAC495271d0F",
    "value": 0,
    "nonce": 0,
    "data": "0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000",
    "r": "0x057d733933b12238a2aeb0069b67c6bc58ca8eb6827547274b3bcf4efdad620a",
    "s": "0x00e49937ec81db89ce70ebec5e51b839c0949234d8aad8f8b55a877bd78cc293",
    "v": 1,
    "creates": null,
    "chainId": 31337
}

El recibo de la transacción se puede recuperar desde el endpoint eth_getTransactionReceipt. Dependiendo del nodo en el cual estés ejecutando esta consulta, puede que recibas información adicional además de los datos del recibo de transacción.

Este es el recibo de transacción que recibí de mi copia local de la red principal de Ethereum:

{
    "to": "0x6B175474E89094C44Da98b954EedeAC495271d0F",
    "from": "0x6fC27A75d76d8563840691DDE7a947d7f3F179ba",
    "contractAddress": null,
    "transactionIndex": 0,
    "gasUsed": 34706,
    "logsBloom": "0x00000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000008000000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000010000000000000004000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000002000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000",
    "blockHash": "0x8b6d44d6cf39d01181b90677f8a77a2605d6e70c40d649eda659499063a19c77",
    "transactionHash": "0xbf77c4a9590389b0189494aeb2b2d68dc5926a5e20430fb5bc3c610b59db3fb5",
    "logs": [
        {
            "transactionIndex": 0,
            "blockNumber": 15166167,
            "transactionHash": "0xbf77c4a9590389b0189494aeb2b2d68dc5926a5e20430fb5bc3c610b59db3fb5",
            "address": "0x6B175474E89094C44Da98b954EedeAC495271d0F",
            "topics": [
                "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
                "0x0000000000000000000000006fc27a75d76d8563840691dde7a947d7f3f179ba",
                "0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045"
            ],
            "data": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000",
            "logIndex": 0,
            "blockHash": "0x8b6d44d6cf39d01181b90677f8a77a2605d6e70c40d649eda659499063a19c77"
        }
    ],
    "blockNumber": 15166167,
    "confirmations": 6, // cantidad de bloques que esperé antes de pedir el recibo de transacción
    "cumulativeGasUsed": 34706,
    "effectiveGasPrice": 9661560402,
    "type": 2,
    "byzantium": true,
    "status": 1
}

¿Viste? Dice "status": 1. ¿Sabes qué significa eso?

¡Éxito!

Palabras finales

Sin dudas esta historia es mucho más larga.

Me animo a decir que es interminable. Siempre hay un «pero» más. Otra nota al margen. Una ruta de ejecución alternativa que no tomé. Otra implementación de los nodos. Otra instrucción de la EVM que pude haberme salteado. Otra billetera que es justo la que vos usas y maneja las cosas de manera diferente.

Todas cosas que nos acercarían un poquito más a encontrar «La Verdad» de lo que ocurre cuando transferís 1 DAI.

Por suerte, nunca quise contar eso. Espero que las últimas 10 000 palabras no te hayan confundido al respecto 😛. Dejame aclarar.

En retrospectiva, este artículo es producto de mezclar mi curiosidad con mi frustración.

Curiosidad, porque estuve trabajando en seguridad de contratos inteligentes de Ethereum por más de 4 años y, sin embargo, nunca dediqué tanto tiempo como me hubiese gustado a explorar manualmente, en profundidad, las complejidades de la capa base de la red. Siempre quise adquirir esa experiencia de primera mano, estudiando la ejecución del protocolo de Ethereum punta a punta. Pero los contratos siempre se metían en el medio. Ahora que logré encontrar aguas más calmas, me pareció que era el momento indicado para navegar hacia a las raíces.

Pero la curiosidad no me alcanzaba. Necesitaba una excusa. Un disparador. Sabía que lo que tenía en mente iba a ser difícil. Así que necesitaba una razón lo suficientemente fuerte no sólo para empezar, sino también, volver a empezar cada vez que me sintiera cansado de tratar de entender el código de Ethereum.

La encontré donde menos lo esperaba. En la frustración.

Frustración ante la falta de transparencia a la que tanto nos acostumbramos cuando mandamos dinero. Si alguna vez tuviste que hacerlo desde un tercer mundo bajo controles de capital cada ves más estrictos, no te hace falta que diga que la cuestión se pone aún más surrealista. Así que quería recordarme que podemos ir hacia algo mejor. Y decidí hacer catarsis acá.

Escribir esto también me sirvió como recordatorio. De que si uno logra escaparle al ruido, los precios, a los monitos en JPEGs de colores, a los ponzis, a los rugpulls, a los robos, todavía hay valor acá. Estas no son monedas «mágicas» de Internet. Acá hay matemática, criptografía y ciencia de la computación real. Y encima es de código abierto. Podés ver cómo se mueve cada una de las piezas. Casi que podés tocarlas.

No importa el día ni la hora. No importa quién sos. No importa de dónde venís.

Así que pido disculpas por lo clickbait del título. Este artículo no se trata de lo que pasa cuando transferís 1 DAI.

Se trata de tener la posibilidad de entenderlo.


⭐ Mención especial para Candela Dos Ramos y Mauricio Streicher por su trabajo para traducir esta locura del inglés al castellano ⭐