Crea tu primera app web3 con Solidity y React

¡Hola desarrolladores!

En los últimos meses ha crecido exponencialmente el interés en el desarrollo de aplicaciones web3. Se está construyendo la historia de la tecnología delante de nuestros ojos y ¡tú puedes ser parte de esta revolución! Pero ¿Por dónde empezamos? ¿Es tan difícil como parece? ¿Es esto el salvaje oeste?

En este artículo vamos a ver de forma práctica cómo construir una app en web3 con Solidity y React que nos permitirá guardar los mensajes que nos manden los usuarios en la blockchain de Ethereum.

Este artículo está basado en el proyecto de Solidity de Buildspace. Te recomiendo que, aunque sigas este post, te apuntes a Buildspace y vayas subiendo tu progreso. Ahí encontrarás más detalles y ¡Puedes ganar un NFT y hasta encontrar trabajo!

Pero, vayamos por partes como dijo Jack el Destripador.

Prepara tu entorno y programa tu primer Smart Contract

Lo primero que vamos a hacer es preparar nuestras herramientas. Para ello, lo primero que haremos es crear una carpeta, inicializar npm e instalar hardhat. Nosotros llamaremos a la carpeta mis-saludos. Para instalar hardhat utiliza:

npm install –save-dev hardhat

Después, pondremos en marcha el proyecto de ejemplo con:

npx hadhat

Puedes aceptar todo lo que te diga por defecto. Este proceso puede durar unos minutos, no te preocupes.

Por último, nos aseguraremos de que todo funciona correctamente lanzando los siguientes comandos:

npx hardhat compile
npx hardhat test

Si te aparece algo como la foto que hay más abajo, ¡Enhorabuena! Ya estás listo para programar tu primer contrato.

Antes de nada, borra el archivo simple-test.js en test, simple-script.js en scripts y Greeter.sol en contracts. Somos profesionales, no necesitamos código de segunda mano.

Vayamos a lo importante. Queremos programar un contrato que nos permita mandar un 👋 y que lleve la cuenta de todos los que hemos recibido. Siéntete libre de aplicar esto a cualquier otra cosa que se te ocurra.

¡Al lío! Vamos a empezar por la estructura. Crea un archivo llamado WavePortal.sol bajo el directorio contracts que contenga lo siguiente:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "hardhat/console.sol";
contract WavePortal {
    uint256 totalWaves;
    constructor() {
        console.log("Yo yo, soy un contrato y soy inteligente");
    }
    function wave() public {
        totalWaves += 1;
        console.log("%s ha saludado!", msg.sender);
    }
    function getTotalWaves() public view returns (uint256) {
        console.log("Tenemos un total de %d saludos!", totalWaves);
        return totalWaves;
    }
}

Como puedes ver, un contrato se parece bastante a una clase. Lo primero que nos encontramos es un comentario que indica el identificador de licencia SPDX (puedes googlear lo que significa esto), después vemos la línea donde declaramos la versión de solidity que vamos a utilizar, importamos una funcionalidad de hardhat que nos permite hacer logs y montamos nuestro contrato con un par de funciones que nos permiten almacenar los saludos. Fácil ¿No?

Como buen desarrollador que eres estarás pensando – Ok, todo guay pero ¿Cómo pruebo que esto funciona? No te impacientes, es justo lo que vamos a ver ahora : )

En la carpeta scipts crea un archivo llamado run.js que contendrá esto:

const main = async () => {
  const [owner, randomPerson] = await hre.ethers.getSigners();
  const waveContractFactory = await hre.ethers.getContractFactory('WavePortal');
  const waveContract = await waveContractFactory.deploy();
  await waveContract.deployed();

  console.log('Contrato desplegado en:', waveContract.address);
  console.log('Contrato desplegado por:', owner.address);

  let waveCount;
  waveCount = await waveContract.getTotalWaves();

  let waveTxn = await waveContract.wave();
  await waveTxn.wait();

  waveCount = await waveContract.getTotalWaves();

  waveTxn = await waveContract.connect(randomPerson).wave();
  await waveTxn.wait();

  waveCount = await waveContract.getTotalWaves();
};

const runMain = async () => {
  try {
    await main();
    process.exit(0);
  } catch (error) {
    console.log(error);
    process.exit(1);
  }
};

runMain();

La segunda línea nos permite crear direcciones de carteras coger nuestro contrato. Para poder desplegar algo en la blockchain, ¡necesitamos tener una dirección de cartera!

Hardhat lo hace por nosotros mágicamente en segundo plano, aquí he cogido la dirección de cartera del propietario del contrato y también he cogido una dirección de cartera aleatoria y la he llamado randomPerson.

Después, simplemente esperaramos a que se despliegue y loggeamos la dirección del contrato, lanzamos un par de waves y los loggeamos. ¿No hay que importar nada? Nope, hardhat inyecta el objeto hre cada vez que utilizamos npx hardhat y el comando que deseemos.

Si todo ha ido bien, deberías ver algo así en la consola:

¡Genial! Ahora que sabemos que todo funciona correctamente, ahora vamos a desplegar el contrato en una red local. ¿No es eso lo que hemos hecho antes? Bueno, no exactamente. Cuando utilizas scripts/run.js en realidad estás:

  1. Creando una nueva red local de Ethereum.
  2. Desplegando tu contrato.
  3. Entonces, cuando el script termina, Hardhat automáticamente destruye esa red local.

Queremos una red que no se destruya. Para eso ve a tu terminal, abre una nueva pestaña y ejecuta

npx hardhat node

BOOM. Acabas de iniciar una red local de Ethereum que se mantiene viva. Y, como puedes ver Hardhat nos dio 20 cuentas para trabajar y les dio a todos 10000 ETH ¡ahora somos ricos! El mejor proyecto de la historia.

Ahora mismo, esto es sólo un blockchain vacío. ¡No hay bloques!

¡Queremos crear un nuevo bloque y poner nuestro smart contract en él! Hagámoslo.

En la carpeta scripts, crea un archivo llamado deploy.js. Aquí está el código que debes incluir. Es muy similar a run.js.

const main = async () => {
  const [deployer] = await hre.ethers.getSigners();
  const accountBalance = await deployer.getBalance();

  console.log('Desplegando contrato con la cuenta: ', deployer.address);
  console.log('Saldo de la cuenta: ', accountBalance.toString());
  const Token = await hre.ethers.getContractFactory('WavePortal');
  const portal = await Token.deploy();
  await portal.deployed();
  console.log('Dirección de WavePortal: ', portal.address);
};
const runMain = async () => {
  try {
    await main();
    process.exit(0);
  } catch (error) {
    console.error(error);
    process.exit(1);
  }
};
runMain();

Vamos a desplegar, en tu terminal, en la ventana que NO mantiene viva la red de Ethereum, ejecuta el siguiente comando:

npx hardhat run scripts/deploy.js --network localhost

Deberías ver algo así:

¡Hemos desplegado el contrato, y también tenemos su dirección en el blockchain! Nuestro sitio web va a necesitar esto para saber dónde buscar su contrato en la blockchain. (Imagina que tuviera que buscar nuestro contrato en toda la blockchain. Eso sería... un rollo).

En tu terminal que mantiene viva la red local ¡verás algo nuevo!

INTERESANTE. Pero... ¿qué es el gas? ¿Qué significa el bloque #1? ¿Qué es el código grande junto a "Transaction"? Deberías intentar buscar estas cosas en Google.

Prepara tu cliente con React

¡Es hora de empezar a trabajar en nuestro sitio web! Nuestro contrato es bastante simple, pero ¡vamos a aprender cómo nuestro front end puede interactuar con nuestro contrato lo antes posible!

Puedes encontrar el proyecto base aquí y aquí el repositorio de replit por si quieres hacer un fork. Puedes usar cualquiera de las dos opciones.

Para poder interactuar con tu web necesitarás tener una cuenta en Metamask.

Ahora cierra el terminal con tu red local de Ethereum en funcionamiento que es donde ejecutaste npx hardhat node. No lo necesitaremos más ;). Principalmente quería mostrarte cómo funciona el despliegue local.

Ahora vamos a hacer el verdadero trabajo, desplegando en la blockchain real.

Crea una cuenta en Alchemy aquí. Lo que hace Alchemy es que nos da una forma sencilla de desplegar en la blockchain real de Ethereum. Esencialmente nos ayuda a difundir nuestra transacción de creación de contrato para que pueda ser recogida por los mineros lo más rápidamente posible. Una vez que la transacción es minada, se transmite a la blockchain como una transacción legítima. A partir de ahí, todo el mundo actualiza su copia de la blockchain.

No vamos a desplegar en la "Ethereum mainnet" hasta el final. ¿Por qué? ¡Porque cuesta $ reales y no vale la pena meter la pata! Vamos a empezar con una "testnet" que es un clon de la "mainnet" pero que utiliza $ falsos para que podamos probar cosas tanto como queramos. Sin embargo, es importante saber que las redes de prueba están dirigidas por mineros reales e imitan los escenarios del mundo real.

Hay varias redes de prueba y la que vamos a utilizar se llama "Rinkeby" que es administrada por la fundación Ethereum.
Para poder desplegar en Rinkeby, necesitamos ether falso. ¿Por qué? Porque si se desplegara en la red mainnet de Ethereum, se utilizaría dinero real. Por lo tanto, las redes de prueba copian el funcionamiento de la red principal, con la única diferencia de que no se utiliza dinero real.

Puedes usar Ethily (en 1 segundo) o el faucet oficial de Rinckeby (mucho más lento pero mayor cantidad)
Ahora vamos a desplegar nuestro contrato en Rinckeby. Para ello ve a hardhat.config.js en el directorio raíz de tu proyecto de smart contract y modifícalo para que se vea así:

require('@nomiclabs/hardhat-waffle');
module.exports = {
  solidity: '0.8.0',
  redes: {
    rinkeby: {
      url: 'TU_ALCHEMY_API_URL',
      cuentas: ['TU_KEY_PRIVADA_DE_RINCKEBY'],
    },
  },
};

Nota: No envíes este archivo a GITHUB. TIENE TU CLAVE PRIVADA. TE HACKEARÁN Y TE ROBARÁN. ESTA CLAVE PRIVADA ES LA MISMA QUE TU CLAVE PRIVADA DE MAINNET. Puedes añadirlo a una variable .env.

Puedes encontrar tu URL de la API desde el panel de control de Alchemy y pegarlo. A continuación, necesitarás tu clave privada de rinkeby (¡no tu dirección pública!) que puedes obtener de metamask y pegarla allí también.

*Nota: El acceso a tu clave privada puede hacerse abriendo MetaMask, cambiando la red a "Rinkeby Test Network" y luego haciendo clic en los tres puntos y seleccionando "Account Details" > "Export Private Key".
*

¿Por qué necesitas usar tu clave privada? Porque para realizar una transacción como el despliegue de un contrato, necesitas "iniciar sesión" en la blockchain. Y, tu nombre de usuario es tu dirección pública y tu contraseña es tu clave privada. Es un poco como iniciar sesión en AWS o GCP para desplegar.

Una vez que tengas tu configuración, estaremos listos para desplegar con el script de despliegue que escribimos antes.

npx hardhat run scripts/deploy.js --network rinkeby

Mi resultado es este:

Copia esa dirección del contrato desplegado en la última línea y guárdala en algún sitio. No la pierdas. La necesitarás para el frontend más adelante :).

Puedes tomar esa dirección y pegarla en Etherscan aquí. Para ver cómo va tu transacción.

Después de toooodo esto, ahora sí, estamos preparados para modificar nuestra web base.

En tu proyecto de React, bajo src, entra en App.jsx y añade el siguiente código:

import React, { useEffect, useState } from "react";
import './App.css';
const App = () => {
  /*
  * Una state variable que usamos para almacenar la cartera pública de nuesrto usuario.
  */
  const [currentAccount, setCurrentAccount] = useState("");

  const checkIfWalletIsConnected = async () => {
   /*
    * Primero nos aseguramos de que tenemos acceso a window.ethereum
    */
    try {
    const { ethereum } = window;
      if (!ethereum) {
        console.log("Asegúrate de que tienes Metamask!");
        return;
    } else {
        console.log("Tenemos el objeto ethereum", ethereum);
    }
    /*
    * Comprobar que estamos autorizados para acceder a la cartera del usuario
    */
    const accounts = await ethereum.request({ method: 'eth_accounts' });
    if (accounts.length !== 0) {
        const account = accounts[0];
        console.log("Cartera autorizada encontrada:", account);
        setCurrentAccount(account);
    } else {
        console.log("No se encontró ninguna cuenta autorizada")
      }
    } catch (error) {
    console.log(error);
    }
  }
  /**
  * Implementa tu método connectWallet aquí
  */
  const connectWallet = async () => {
    try {
    const { ethereum } = window;
    if (!ethereum) {
        alert("Descarga Metamask");
        return;
    }
    const accounts = await ethereum.request({ method: "eth_requestAccounts" });
    console.log("Conectado ", accounts[0]);
    setCurrentAccount(accounts[0]);
    } catch (error) {
    console.log(error)
    }
  }
 /*
  * Esto ejecuta nuestra función cuando se carga la página.
  */
  useEffect(() => {
    checkIfWalletIsConnected();
  }, []) 
  return (
    <div className="mainContainer">
    <div className="dataContainer">
        <div className="header">
        👋 Holaaa!
        </div>
        <div className="bio">
           ¡Soy Álvaro! He trabajado en realidad virtual y fintech. Bastante guay ¿no? ¡Conecta tu cartera de Ethereum y mándame un saludo!
        </div>
        <button className="waveButton" onClick={null}>
        Salúdame
        </button>
        {/*
        * Si no existe ninguna currentAccount renderiza este botón
        */}
        {!currentAccount && (
        <button className="waveButton" onClick={connectWallet}>
            Conecta tu cartera
        </button>
        )}
    </div>
    </div>
  );
}
export default App

No voy a entrar a explicar las partes que son puramente de React porque si no este artículo sería eterno, pero no hay nada demasiado complicado, googlea todo lo que no entiendas.
Si hemos iniciado sesión en Metamask, se inyectará automáticamente un objeto especial llamado ethereum en nuestra ventana. Con él podemos comprobar si estamos autorizados a acceder en la cartera del usuario, si no lo estamos, mostramos un botón para que el usuario conecte su cartera.

Una vez que hemos conseguido conectarnos con la cartera del usuario ¡Podemos llamar a nuestro smart contract!

Para ello, justo debajo de nuestra función connectWallet() copia el siguiente código:

const wave = async () => {
    try {
    const { ethereum } = window;

    if (ethereum) {
        const provider = new ethers.providers.Web3Provider(ethereum);
        const signer = provider.getSigner();
        const wavePortalContract = new ethers.Contract(contractAddress, contractABI, signer);

        let count = await wavePortalContract.getTotalWaves();
        console.log("Recuperado el recuento total de saludos...", count.toNumber());
    } else {
        console.log("¡El objeto Ethereum no existe!");
    }
    } catch (error) {
    console.log(error)
    }
}

ethers es una librería que ayuda a nuestro frontend a hablar con nuestro contrato. Asegúrate de importarla al principio usando import { ethers } from "ethers";.

Un "Provider" es lo que usamos para hablar con los nodos de Ethereum. ¿Recuerdas que usamos Alchemy para desplegar? Bueno, en este caso usamos nodos que Metamask proporciona en segundo plano para enviar/recibir datos de nuestro contrato desplegado.

Conecta esta función a nuestro botón waveButton actualizando onClick de null a wave.

Para que todo esto funcione necesitamos por un lado nuestro contract address (lo que te pedí que guardaras antes) y el contenido del archivo ABI.

Crea una const en tu App.jsx llamada contractAddress que contenga la dirección. Tal que así:

/**
   * ¡Crea aquí una variable que contenga la dirección del contrato desplegado!
   **/
  const contractAddress = "0xd5f08a0ae197482FA808cE84E00E97d940dBD26E";

El archivo ABI es algo que nuestra aplicación web necesita para saber cómo comunicarse con nuestro contrato. Lee sobre esto aquí.

Para obtenerlo, en tu proyecto de solidity dirígete a artifacts/contracts/WavePortal.sol/WavePortal.json y copia el contenido. En tu proyecto de React, crea una carpeta llamada utils bajo src, dentro crea un archivo llamado WavePortal.json y pega todo dentro. Ahora solo lo tienes que importar en tu App.jsx así:

import abi from './utils/WavePortal.json';

Y crear una const para poder usarlo justo debajo de nuestro contractAddress así:

const contractABI = abi.abi;

¡Genial! Ya somos capaces de comunicarnos con nuestro contrato y recoger los datos, ahora vamos a mandar los saludos. Modifica la función wave para que se parezca a esta:

const wave = async () => {
    try {
    const { ethereum } = window;
    if (ethereum) {
        const provider = new ethers.providers.Web3Provider(ethereum);
        const signer = provider.getSigner();
        const wavePortalContract = new ethers.Contract(contractAddress, contractABI, signer);
        let count = await wavePortalContract.getTotalWaves();
        console.log("Recuperado el recuento total de saludos...", count.toNumber());
        /*
        * Ejecutar el wave real de tu smart contract
        */
        const waveTxn = await wavePortalContract.wave(¨👋 ¨); // cambia esto por lo que quieras o ¡permite que los usuario escriban!
        console.log("Minando...", waveTxn.hash);
        await waveTxn.wait();
        console.log("Minado completado...", waveTxn.hash);
        count = await wavePortalContract.getTotalWaves();
        console.log("Recuperado el recuento total de saludos...", count.toNumber());
    } else {
        console.log("¡El objeto Ethereum no existe!");
    }
    } catch (error) {
        console.log(error)
    }
  }

Bastante simple, ¿verdad :)?

Lo impresionante aquí es que mientras la transacción está siendo minada puedes imprimir el hash de la transacción, copiar/pegar en Etherscan, y ver cómo se procesa en tiempo real :).

Cuando ejecutamos esto, verás que el recuento total de saludos se incrementa en 1. También verás que Metamask nos aparece y nos pide que paguemos "gas", el cual pagamos usando nuestros $ falsos. Hay un gran artículo sobre esto aquí. Intenta averiguar qué es el gas :)

Últimos cambios

¡Bien! Ya casi lo tenemos. Lo último que tenemos que hacer es modificar nuestro contrato para guardar todos los mensajes que nos manden. He añadido bastantes comentarios.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "hardhat/console.sol";
contract WavePortal {
    uint256 totalWaves;
    /*
    * Un poco de magia, googlea lo que son los eventos en Solidity
    */
    event NewWave(address indexed from, uint256 timestamp, string message);
    /*
    * He creado un struct llamado Wave.
    * Un struct es básicamente un datatype que nos permite customizar lo que queremos guardar en él.
    */
    struct Wave {
        address waver; // La cartera del usuario que ha saludado.
        string message; // El mensaje que nos ha dejado.
        uint256 timestamp; // El timestamp del momento en el que nos han saludado.
    }
    /*
    * Declaro la variable waves que me permite guardar una lista de structs.
    * ¡Esto es lo que nos permite guardar todos los saludos que nos manden!
     */
    Wave[] waves;

    constructor() {
        console.log("SOY UN SMART CONTRACT. YAY.");
    }
    /*
    * Notarás que he cambiado un poco la función wave un poco
    * ahora requiere un string llamado _message. ¡Es el mensaje que
    * nos mandan del front!
    */
    function wave(string memory _message) public {
        totalWaves += 1;
        console.log("%s ha saludado!", msg.sender);

        /*
        * Aquí es donde guardamos realmente los datos de los saludos en la lista.
        */
        waves.push(Wave(msg.sender, _message, block.timestamp));

        /*
        * He añadido algunas cosillas aquí, ¡googléalo e intenta entender qué es!
        * Haznos saber lo que aprendes en #general-chill-chat
        */
        emit NewWave(msg.sender, block.timestamp, _message);
    }

    /*
    * he añadido la función getAllWaves que nos devuelve la lista de structs waves.
    * ¡Eso nos facilitará la recuperación de los saludos desde la web!
    */
    function getAllWaves() public view returns (Wave[] memory) {
        return waves;
    }

    function getTotalWaves() public view returns (uint256) {
        // Opcional: ¡Añade esta línea si quieres que el contrato imprima el valor!
        // También lo vamos a imprimir en run.js.
        console.log("Tenemos %d saludos en total!", totalWaves);
        return totalWaves;
    }
}

Intenta modificar tu run.js para probarlo : ). A mi me devuelve esto:

¡Genial! Ahora tienes que volver a desplegarlo y volver a copiar la dirección de tu contrato y el ABI.
Estas variables cambian cada vez que despliegas un contrato. No estás actualizando el que tenías ¡Estás creando uno nuevo!

Repite conmigo:

  1. Desplegar.
  2. Actualizar la dirección del contrato en nuestro frontend.
  3. Actualizar el archivo abi en nuestro frontend. Mucha gente se olvida de estos 3 pasos. Intenta que no te pase.

Esta es la nueva función que he añadido a App.js para enchufarlo todo a nuestro front:

const [currentAccount, setCurrentAccount] = useState("");
  /*
   *Propiedad de estado para almacenar todos los saludos
   */
  const [allWaves, setAllWaves] = useState([]);
  const contractAddress ="0xd5f08a0ae197482FA808cE84E00E97d940dBD26E";
  /*
   * Crea un método que obtenga todos los saludos de tu contrato
   */
  const getAllWaves = async () => {
    try {
    const { ethereum } = window;
    if (ethereum) {
        const provider = new ethers.providers.Web3Provider(ethereum);
        const signer = provider.getSigner();
        const wavePortalContract = new ethers.Contract(contractAddress, contractABI, signer);

        /*
        * Llama al método getAllWaves desde tu Smart Contract
        */
        const waves = await wavePortalContract.getAllWaves();
        /*
        * Sólo necesitamos la dirección, el timestamp y el mensaje en nuestro UI, así que
        * elígelos
        */
        let wavesCleaned = [];
        waves.forEach(wave => {
        wavesCleaned.push({
            dirección: wave.waver,
            timestamp: new Date(wave.timestamp * 1000),
            mensaje: wave.mensaje
        });
        });
        /*
        * Almacena nuestros datos en React State
        */
        setAllWaves(wavesCleaned);
    } else {
        console.log("¡El objeto Ethereum no existe!")
    }
    } catch (error) {
    console.log(error);
    }
  }

Bastante simple ¿No? Ahora tenemos que llamar a nuestra función getAllWaves() ¿Cúando? Pues cuando sepamos que el usuario tiene su cartera conectada y autorizada, voy a dejar que trates de averiguar dónde ponerlo exactamente. Piensa que debemos saber que tenemos la cuenta y que está autorizada.

Lo último que vamos a hacer es actualizar nuestro HTML para que nos muestre los datos así:

return (
    <div className="mainContainer">
      <div className="dataContainer">
        <div className="header">
        👋 Holaaa!
        </div>

        <div className="bio">
          ¡Soy Álvaro! He trabajado en realidad virtual y fintech. Bastante guay ¿no? ¡Conecta tu cartera de Ethereum y mándame un saludo!
        </div>

        <button className="waveButton" onClick={wave}>
          Salúdame
        </button>

        {!currentAccount && (
          <button className="waveButton" onClick={connectWallet}>
            Conecta tu cartera
          </button>
        )}

        {allWaves.map((wave, index) => {
          return (
            <div key={index} style={{ backgroundColor: "OldLace", marginTop: "16px", padding: "8px" }}>
              <div>Dirección: {wave.address}</div>
              <div>Tiempo: {wave.timestamp.toString()}</div>
              <div>Mensaje: {wave.message}</div>
            </div>)
        })}
      </div>
    </div>
  );

¡¡¡LO CONSEGUISTEEEE!!!

Tu app está lista para ser usada. Espero que hayas disfrutado mucho creando este proyecto y que lo adaptes a lo que tú quieras.

Me harías muy feliz si compartes tu proyecto en twitter y me etiquetas para que lo pueda ver (@metasurfero). Si quieres también puedes etiquetar a Buildspace, son una comunidad fantástica.

¡Hasta la próxima desarrolladores!

35