Qué pasa cuando enviás 1 DAI
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
- Construyendo la transacción
- Recepción
- Propagación
- Preparación del trabajo e inclusión de la transacción
- Ejecución
- Sellado del bloque
- Transmisión del bloque
- Verificación del bloque
- Recuperando la transacción
- 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.
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:
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:
Fíjate el siguiente fragmento de la respuesta:
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 tú 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:
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.
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:
Y tras efectivamente recibir la transacción, acá está la respuesta con el hash:
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...
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:
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 ⭐