DEV Community

Cover image for Introducción a los Contratos Inteligentes con Solidity
pepe_metaverse
pepe_metaverse

Posted on

Introducción a los Contratos Inteligentes con Solidity

El mundo de la programación puede llegar a ser un poco abrumador cuando no sabemos por dónde debemos empezar, Así que este puede ser un punto de entrada para todos los principiantes que están empezando en el desarrollo con Solidity.

Tener conocimientos previos de algún otro lenguaje de programación como Python o JavaScript haría las cosas mucho más fáciles, pero si llegaste a este post, como yo, sin saber nada, tratare de hacer este post lo mas amigable posible.

¿Qué es un Contrato Inteligente?

Un contrato inteligente es un programa que se ejecuta en una dirección de la blockchain de Ethereum. Están compuestos por una serie de datos y funciones que pueden ejecutarse al recibir una transacción.

¿En qué consiste un contrato inteligente?

Los contratos inteligentes constan de diferentes partes:

  • Datos
  • Variables de entorno
  • Funciones
  • Eventos y registros

A continuación, tendremos una breve explicación de cada parte.

Datos

Cualquier dato del contrato debe ser asignado a una ubicación: ya sea al almacenamiento o a la memoria. Es costoso modificar el almacenamiento en un contrato inteligente, por lo que debes considerar dónde debes almacenar tus datos.

Almacenamiento

Los datos persistentes se denominan almacenamiento y están representados por variables de estado. Estos valores se almacenan permanentemente en el blockchain. Necesitas declarar el tipo de una variable para que el contrato pueda hacer un seguimiento de la cantidad de almacenamiento que necesita cuando se compila.
Es comparativamente costoso leer, y aún más inicializar y modificar el almacenamiento. Debido a este coste, deberías minimizar lo que almacenas en el almacenamiento constante a lo que el contrato necesita para ejecutarse. Almacena los datos como los cálculos derivados, la caché y los agregados fuera del contrato. Un contrato no puede leer ni escribir en ningún almacenamiento aparte del suyo.

// Ejemplo de Solidity
contract Storage {
    uint savedData; // Variable de estado
}
Enter fullscreen mode Exit fullscreen mode

Es probable que estés familiarizado con la mayoría de los tipos si ya has programado en algún lenguaje orientado a objetos. Sin embargo, el tipo address debería ser nuevo para ti si eres nuevo en el desarrollo de Ethereum.

Un tipo address puede contener una dirección de Ethereum que equivale a 20 bytes o 160 bits. Devuelve una notación hexadecimal con un 0x inicial.

Otros tipos pueden incluir:

  • booleano
  • entero
  • números de punto fijo
  • matrices de bytes de tamaño fijo
  • matrices de bytes de tamaño dinámico
  • literales racionales y enteros
  • Literales de cadena
  • Literales hexadecimales
  • Enums

Para poder entender el tema de Tipos, deberías revisar la documentación oficial de Tipos de Solidity en la web oficial del proyecto para tener una explicación mucho más detallada.

Memoria

Los valores que sólo se almacenan durante un corto periodo de tiempo durante la ejecución de una función del contrato se denominan variables de memoria. Como no se almacenan permanentemente en la cadena de bloques, su uso es mucho más barato.

Para saber más sobre los datos que almacena EVM, consulta la documentación de Solidity.

Variables de entorno

Además de las variables que definimos en nuestros contratos, hay algunas variables globales que son especiales. Estas se utilizan principalmente para proporcionar información sobre la Blockchain, como la transacción actual, o como funciones de uso general.

Algunos ejemplos son:

  • block.chainid (uint): el identificador de la cadena actual.
  • block.timestamp (uint): la marca de tiempo del bloque actual en segundos desde la época unix.
  • msg.value (uint): número de wei/Ether enviados con el mensaje.
  • msg.sender (dirección): remitente del mensaje (llamada actual).
  • blockhash (uint blockNumber) devuelve (bytes32): hash del bloque dado cuando blocknumber es uno de los 256 bloques más recientes; en caso contrario devuelve cero.

Funciones

Las funciones son las unidades ejecutables de código. En otras palabras, las funciones pueden obtener o establecer información en respuesta a las transacciones entrantes en la Blockchain.

Las llamadas a las funciones pueden ocurrir interna o externamente y tienen diferentes niveles de visibilidad hacia otros contratos. Las funciones aceptan parámetros y devuelven variables para pasar parámetros y valores entre ellas.

Visibilidad de las funciones
Solidity conoce dos tipos de llamadas a funciones: las externas que sí crean una llamada a un mensaje EVM real y las internas que no lo hacen. Además, las funciones internas pueden hacerse inaccesibles a los contratos derivados. Esto da lugar a cuatro tipos de visibilidad para las funciones.

  • external (Son funciones que pueden ser llamadas desde otro contrato).
  • public (Son funciones públicas y pueden ser llamadas internamente o a través de llamadas a mensajes).
  • internal (Son funciones que solo se pueden llamar desde el contrato actual o los contratos que derivan de él).
  • private (Las funciones privadas son como las internas pero no son visibles en los contratos derivados).

Para tener una explicaciones más detalladas sobre cada tipo de visibilidad, puedes visitar la documentación de Solidity sobre la visibilidad de las funciones.

// Un ejemplo en Solidity para mostrar las diferentes partes de una función
function setUsername(string memory value) public {
    userName = value;
}
Enter fullscreen mode Exit fullscreen mode
  • la function setUsername esta almacenada en la memoria.
  • El valor del parámetro es de tipo string y se pasa a la función: setUsername.
  • Y la funcion está declarada como public, lo que significa que cualquiera puede acceder a él.

Ver Funciones

Estas funciones prometen no modificar el estado de los datos del contrato. Los ejemplos más comunes son las funciones "getter", que se pueden utilizar para recibir el saldo de un usuario, por ejemplo.

// Ejemplo Solidity
function balanceOf(address _owner) public view returns (uint256 _balance) {
    return ownerPizzaCount[_owner];
}
Enter fullscreen mode Exit fullscreen mode

¿A qué se considera estado modificador?

  • Escribir en variables de estado.
  • Emisión de eventos.
  • Creación de otros contratos.
  • Uso de la autodestrucción.
  • Enviando éter a través de llamadas.
  • Llamar a cualquier función no marcada como vista o pura.
  • Usar llamadas de bajo nivel.
  • Usar ensamblaje inline que contenga ciertos opcodes

Funciones de Constructor

Las funciones de Constructor sólo se ejecutan una vez cuando el contrato se despliega por primera vez. Al igual que los constructores de muchos lenguajes de programación basados en clases, estas funciones suelen inicializar las variables de estado con los valores especificados.

// Ejemplo de Solidity
// Inicializa los datos del contrato, estableciendo el propietario `owner`
// a la dirección del creador del contrato.
constructor() public {
    // odos los contratos inteligentes dependen de transacciones externas para activar sus funciones.
    // `msg` es una variable global que incluye datos relevantes sobre la transacción dada.
    // como la dirección del emisor y el valor de ETH incluido en la transacción.
    // Más información: https://solidity.readthedocs.io/en/v0.5.10/units-and-global-variables.html#block-and-transaction-properties
    owner = msg.sender;
}
Enter fullscreen mode Exit fullscreen mode

Funciones incorporadas

Además de las variables y funciones que definamos en nuestro contrato, existen algunas funciones especiales incorporadas. El ejemplo más obvio es:

  • address.send()

Esta funcion incorporada permite que los contratos envíen ETH a otras cuentas.

Funciones de Escritura

Todas nuestras funciónes necesitan:

  • Una variable de parámetro y type (si acepta parámetros)
  • Una declaración de interno/external
  • Una declaración de pure/view/payable
  • Un tipo de return (si la función debe devolver un valor)
pragma solidity >=0.4.0 <=0.6.0;

contract ExampleDapp {
    string dapp_name; // Variable de estado

    // Se llama cuando se despliega el contrato y se inicializa el valor
    constructor() public {
        dapp_name = "My Example dapp";
    }

    // Función de Get nos devuelve el valor solicitado
    function read_name() public view returns(string) {
        return dapp_name;
    }

    // Función Set nos permite establecer un valor 
    function update_name(string value) public {
        dapp_name = value;
    }
}
Enter fullscreen mode Exit fullscreen mode

Hasta ahora hemos aprendido las partes básicas de un contrato inteligente escrito en Solidity. Cuando unimos todas estas funciones, variables y eventos podemos formar un grupo de código que ejecutara nuestros comandos dentro de la Blockchain, comandos como enviar Tokens a una dirección especifica, crear Tokens nuevos, quemar Tokens, entre otras opciones.

Hasta ahora hemos aprendido las partes básicas de un contrato inteligente escrito en Solidity. Cuando unimos todas estas funciones, variables y eventos podemos formar un grupo de código que ejecutara nuestros comandos dentro de la Blockchain, comandos como enviar Tokens a una dirección especifica, crear Tokens nuevos, quemar Tokens, entre otros.

Se que este tipo de tutoriales son bastante largos y probablemente al principio te puedan parecer aburridos, pero para motivarte dejare abajo algunos contratos para que puedas empezar a interactuar con ellos y puedas tener una idea más clara de cómo se ve un contrato inteligente.

Atención

Los siguientes contratos se crearon como EJEMPLOS dentro de [ethereum.org](, no debes lanzar ninguno de estos contratos a la Mainnet de Ethereum.

Contrato Hello World

// Specifies the version of Solidity, using semantic versioning.
// Learn more: https://solidity.readthedocs.io/en/v0.5.10/layout-of-source-files.html#pragma
pragma solidity ^0.5.10;

// Defines a contract named `HelloWorld`.
// A contract is a collection of functions and data (its state).
// Once deployed, a contract resides at a specific address on the Ethereum blockchain.
// Learn more: https://solidity.readthedocs.io/en/v0.5.10/structure-of-a-contract.html
contract HelloWorld {

    // Declares a state variable `message` of type `string`.
    // State variables are variables whose values are permanently stored in contract storage.
    // The keyword `public` makes variables accessible from outside a contract
    // and creates a function that other contracts or clients can call to access the value.
    string public message;

    // Similar to many class-based object-oriented languages, a constructor is
    // a special function that is only executed upon contract creation.
    // Constructors are used to initialize the contract's data.
    // Learn more: https://solidity.readthedocs.io/en/v0.5.10/contracts.html#constructors
    constructor(string memory initMessage) public {
        // Accepts a string argument `initMessage` and sets the value
        // into the contract's `message` storage variable).
        message = initMessage;
    }

    // A public function that accepts a string argument
    // and updates the `message` storage variable.
    function update(string memory newMessage) public {
        message = newMessage;
    }
}
Enter fullscreen mode Exit fullscreen mode

Contrato token ERC20

pragma solidity ^0.5.10;

contract Token {
    // An `address` is comparable to an email address - it's used to identify an account on Ethereum.
    // Addresses can represent a smart contract or an external (user) accounts.
    // Learn more: https://solidity.readthedocs.io/en/v0.5.10/types.html#address
    address public owner;

    // A `mapping` is essentially a hash table data structure.
    // This `mapping` assigns an unsigned integer (the token balance) to an address (the token holder).
    // Learn more: https://solidity.readthedocs.io/en/v0.5.10/types.html#mapping-types
    mapping (address => uint) public balances;

    // Events allow for logging of activity on the blockchain.
    // Ethereum clients can listen for events in order to react to contract state changes.
    // Learn more: https://solidity.readthedocs.io/en/v0.5.10/contracts.html#events
    event Transfer(address from, address to, uint amount);

    // Initializes the contract's data, setting the `owner`
    // to the address of the contract creator.
    constructor() public {
        // All smart contracts rely on external transactions to trigger its functions.
        // `msg` is a global variable that includes relevant data on the given transaction,
        // such as the address of the sender and the ETH value included in the transaction.
        // Learn more: https://solidity.readthedocs.io/en/v0.5.10/units-and-global-variables.html#block-and-transaction-properties
        owner = msg.sender;
    }

    // Creates an amount of new tokens and sends them to an address.
    function mint(address receiver, uint amount) public {
        // `require` is a control structure used to enforce certain conditions.
        // If a `require` statement evaluates to `false`, an exception is triggered,
        // which reverts all changes made to the state during the current call.
        // Learn more: https://solidity.readthedocs.io/en/v0.5.10/control-structures.html#error-handling-assert-require-revert-and-exceptions

        // Only the contract owner can call this function
        require(msg.sender == owner, "You are not the owner.");

        // Enforces a maximum amount of tokens
        require(amount < 1e60, "Maximum issuance exceeded");

        // Increases the balance of `receiver` by `amount`
        balances[receiver] += amount;
    }

    // Sends an amount of existing tokens from any caller to an address.
    function transfer(address receiver, uint amount) public {
        // The sender must have enough tokens to send
        require(amount <= balances[msg.sender], "Insufficient balance.");

        // Adjusts token balances of the two addresses
        balances[msg.sender] -= amount;
        balances[receiver] += amount;

        // Emits the event defined earlier
        emit Transfer(msg.sender, receiver, amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

Activo digital único

pragma solidity ^0.5.10;

// Imports symbols from other files into the current contract.
// In this case, a series of helper contracts from OpenZeppelin.
// Learn more: https://solidity.readthedocs.io/en/v0.5.10/layout-of-source-files.html#importing-other-source-files

import "../node_modules/@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "../node_modules/@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import "../node_modules/@openzeppelin/contracts/introspection/ERC165.sol";
import "../node_modules/@openzeppelin/contracts/math/SafeMath.sol";

// The `is` keyword is used to inherit functions and keywords from external contracts.
// In this case, `CryptoPizza` inherits from the `IERC721` and `ERC165` contracts.
// Learn more: https://solidity.readthedocs.io/en/v0.5.10/contracts.html#inheritance
contract CryptoPizza is IERC721, ERC165 {
    // Uses OpenZeppelin's SafeMath library to perform arithmetic operations safely.
    // Learn more: https://docs.openzeppelin.com/contracts/2.x/api/math#SafeMath
    using SafeMath for uint256;

    // Constant state variables in Solidity are similar to other languages
    // but you must assign from an expression which is constant at compile time.
    // Learn more: https://solidity.readthedocs.io/en/v0.5.10/contracts.html#constant-state-variables
    uint256 constant dnaDigits = 10;
    uint256 constant dnaModulus = 10 ** dnaDigits;
    bytes4 private constant _ERC721_RECEIVED = 0x150b7a02;

    // Struct types let you define your own type
    // Learn more: https://solidity.readthedocs.io/en/v0.5.10/types.html#structs
    struct Pizza {
        string name;
        uint256 dna;
    }

    // Creates an empty array of Pizza structs
    Pizza[] public pizzas;

    // Mapping from pizza ID to its owner's address
    mapping(uint256 => address) public pizzaToOwner;

    // Mapping from owner's address to number of owned token
    mapping(address => uint256) public ownerPizzaCount;

    // Mapping from token ID to approved address
    mapping(uint256 => address) pizzaApprovals;

    // You can nest mappings, this example maps owner to operator approvals
    mapping(address => mapping(address => bool)) private operatorApprovals;

    // Internal function to create a random Pizza from string (name) and DNA
    function _createPizza(string memory _name, uint256 _dna)
        // The `internal` keyword means this function is only visible
        // within this contract and contracts that derive this contract
        // Learn more: https://solidity.readthedocs.io/en/v0.5.10/contracts.html#visibility-and-getters
        internal
        // `isUnique` is a function modifier that checks if the pizza already exists
        // Learn more: https://solidity.readthedocs.io/en/v0.5.10/structure-of-a-contract.html#function-modifiers
        isUnique(_name, _dna)
    {
        // Adds Pizza to array of Pizzas and get id
        uint256 id = SafeMath.sub(pizzas.push(Pizza(_name, _dna)), 1);

        // Checks that Pizza owner is the same as current user
        // Learn more: https://solidity.readthedocs.io/en/v0.5.10/control-structures.html#error-handling-assert-require-revert-and-exceptions

        // note that address(0) is the zero address,
        // indicating that pizza[id] is not yet allocated to a particular user.

        assert(pizzaToOwner[id] == address(0));

        // Maps the Pizza to the owner
        pizzaToOwner[id] = msg.sender;
        ownerPizzaCount[msg.sender] = SafeMath.add(
            ownerPizzaCount[msg.sender],
            1
        );
    }

    // Creates a random Pizza from string (name)
    function createRandomPizza(string memory _name) public {
        uint256 randDna = generateRandomDna(_name, msg.sender);
        _createPizza(_name, randDna);
    }

    // Generates random DNA from string (name) and address of the owner (creator)
    function generateRandomDna(string memory _str, address _owner)
        public
        // Functions marked as `pure` promise not to read from or modify the state
        // Learn more: https://solidity.readthedocs.io/en/v0.5.10/contracts.html#pure-functions
        pure
        returns (uint256)
    {
        // Generates random uint from string (name) + address (owner)
        uint256 rand = uint256(keccak256(abi.encodePacked(_str))) +
            uint256(_owner);
        rand = rand % dnaModulus;
        return rand;
    }

    // Returns array of Pizzas found by owner
    function getPizzasByOwner(address _owner)
        public
        // Functions marked as `view` promise not to modify state
        // Learn more: https://solidity.readthedocs.io/en/v0.5.10/contracts.html#view-functions
        view
        returns (uint256[] memory)
    {
        // Uses the `memory` storage location to store values only for the
        // lifecycle of this function call.
        // Learn more: https://solidity.readthedocs.io/en/v0.5.10/introduction-to-smart-contracts.html#storage-memory-and-the-stack
        uint256[] memory result = new uint256[](ownerPizzaCount[_owner]);
        uint256 counter = 0;
        for (uint256 i = 0; i < pizzas.length; i++) {
            if (pizzaToOwner[i] == _owner) {
                result[counter] = i;
                counter++;
            }
        }
        return result;
    }

    // Transfers Pizza and ownership to other address
    function transferFrom(address _from, address _to, uint256 _pizzaId) public {
        require(_from != address(0) && _to != address(0), "Invalid address.");
        require(_exists(_pizzaId), "Pizza does not exist.");
        require(_from != _to, "Cannot transfer to the same address.");
        require(_isApprovedOrOwner(msg.sender, _pizzaId), "Address is not approved.");

        ownerPizzaCount[_to] = SafeMath.add(ownerPizzaCount[_to], 1);
        ownerPizzaCount[_from] = SafeMath.sub(ownerPizzaCount[_from], 1);
        pizzaToOwner[_pizzaId] = _to;

        // Emits event defined in the imported IERC721 contract
        emit Transfer(_from, _to, _pizzaId);
        _clearApproval(_to, _pizzaId);
    }

    /**
     * Safely transfers the ownership of a given token ID to another address
     * If the target address is a contract, it must implement `onERC721Received`,
     * which is called upon a safe transfer, and return the magic value
     * `bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))`;
     * otherwise, the transfer is reverted.
    */
    function safeTransferFrom(address from, address to, uint256 pizzaId)
        public
    {
        // solium-disable-next-line arg-overflow
        this.safeTransferFrom(from, to, pizzaId, "");
    }

    /**
     * Safely transfers the ownership of a given token ID to another address
     * If the target address is a contract, it must implement `onERC721Received`,
     * which is called upon a safe transfer, and return the magic value
     * `bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))`;
     * otherwise, the transfer is reverted.
     */
    function safeTransferFrom(
        address from,
        address to,
        uint256 pizzaId,
        bytes memory _data
    ) public {
        this.transferFrom(from, to, pizzaId);
        require(_checkOnERC721Received(from, to, pizzaId, _data), "Must implement onERC721Received.");
    }

    /**
     * Internal function to invoke `onERC721Received` on a target address
     * The call is not executed if the target address is not a contract
     */
    function _checkOnERC721Received(
        address from,
        address to,
        uint256 pizzaId,
        bytes memory _data
    ) internal returns (bool) {
        if (!isContract(to)) {
            return true;
        }

        bytes4 retval = IERC721Receiver(to).onERC721Received(
            msg.sender,
            from,
            pizzaId,
            _data
        );
        return (retval == _ERC721_RECEIVED);
    }

    // Burns a Pizza - destroys Token completely
    // The `external` function modifier means this function is
    // part of the contract interface and other contracts can call it
    function burn(uint256 _pizzaId) external {
        require(msg.sender != address(0), "Invalid address.");
        require(_exists(_pizzaId), "Pizza does not exist.");
        require(_isApprovedOrOwner(msg.sender, _pizzaId), "Address is not approved.");

        ownerPizzaCount[msg.sender] = SafeMath.sub(
            ownerPizzaCount[msg.sender],
            1
        );
        pizzaToOwner[_pizzaId] = address(0);
    }

    // Returns count of Pizzas by address
    function balanceOf(address _owner) public view returns (uint256 _balance) {
        return ownerPizzaCount[_owner];
    }

    // Returns owner of the Pizza found by id
    function ownerOf(uint256 _pizzaId) public view returns (address _owner) {
        address owner = pizzaToOwner[_pizzaId];
        require(owner != address(0), "Invalid Pizza ID.");
        return owner;
    }

    // Approves other address to transfer ownership of Pizza
    function approve(address _to, uint256 _pizzaId) public {
        require(msg.sender == pizzaToOwner[_pizzaId], "Must be the Pizza owner.");
        pizzaApprovals[_pizzaId] = _to;
        emit Approval(msg.sender, _to, _pizzaId);
    }

    // Returns approved address for specific Pizza
    function getApproved(uint256 _pizzaId)
        public
        view
        returns (address operator)
    {
        require(_exists(_pizzaId), "Pizza does not exist.");
        return pizzaApprovals[_pizzaId];
    }

    /**
     * Private function to clear current approval of a given token ID
     * Reverts if the given address is not indeed the owner of the token
     */
    function _clearApproval(address owner, uint256 _pizzaId) private {
        require(pizzaToOwner[_pizzaId] == owner, "Must be pizza owner.");
        require(_exists(_pizzaId), "Pizza does not exist.");
        if (pizzaApprovals[_pizzaId] != address(0)) {
            pizzaApprovals[_pizzaId] = address(0);
        }
    }

    /*
     * Sets or unsets the approval of a given operator
     * An operator is allowed to transfer all tokens of the sender on their behalf
     */
    function setApprovalForAll(address to, bool approved) public {
        require(to != msg.sender, "Cannot approve own address");
        operatorApprovals[msg.sender][to] = approved;
        emit ApprovalForAll(msg.sender, to, approved);
    }

    // Tells whether an operator is approved by a given owner
    function isApprovedForAll(address owner, address operator)
        public
        view
        returns (bool)
    {
        return operatorApprovals[owner][operator];
    }

    // Takes ownership of Pizza - only for approved users
    function takeOwnership(uint256 _pizzaId) public {
        require(_isApprovedOrOwner(msg.sender, _pizzaId), "Address is not approved.");
        address owner = this.ownerOf(_pizzaId);
        this.transferFrom(owner, msg.sender, _pizzaId);
    }

    // Checks if Pizza exists
    function _exists(uint256 pizzaId) internal view returns (bool) {
        address owner = pizzaToOwner[pizzaId];
        return owner != address(0);
    }

    // Checks if address is owner or is approved to transfer Pizza
    function _isApprovedOrOwner(address spender, uint256 pizzaId)
        internal
        view
        returns (bool)
    {
        address owner = pizzaToOwner[pizzaId];
        // Disable solium check because of
        // https://github.com/duaraghav8/Solium/issues/175
        // solium-disable-next-line operator-whitespace
        return (spender == owner ||
            this.getApproved(pizzaId) == spender ||
            this.isApprovedForAll(owner, spender));
    }

    // Check if Pizza is unique and doesn't exist yet
    modifier isUnique(string memory _name, uint256 _dna) {
        bool result = true;
        for (uint256 i = 0; i < pizzas.length; i++) {
            if (
                keccak256(abi.encodePacked(pizzas[i].name)) ==
                keccak256(abi.encodePacked(_name)) &&
                pizzas[i].dna == _dna
            ) {
                result = false;
            }
        }
        require(result, "Pizza with such name already exists.");
        _;
    }

    // Returns whether the target address is a contract
    function isContract(address account) internal view returns (bool) {
        uint256 size;
        // Currently there is no better way to check if there is a contract in an address
        // than to check the size of the code at that address.
        // See https://ethereum.stackexchange.com/a/14016/36603
        // for more details about how this works.
        // TODO Check this again before the Serenity release, because all addresses will be
        // contracts then.
        // solium-disable-next-line security/no-inline-assembly
        assembly {
            size := extcodesize(account)
        }
        return size > 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

¡Por favor, tus comentarios siempre son bien recibidos, déjenme saber que les pareció el Post y si saben de algún otro recurso de habla Hispana que podamos agregar, me encantaría conocerlo y agregarlo!

Y si este contenido te gusto, si no es mucho pedir me encantaría que me pudieras seguir en Redes Sociales para poder alcanzar a más personas.

Instagram
Twitter
TikTok

Saludos, un fuerte abrazo.

Top comments (1)

Collapse
 
algorithmxlabs profile image
AlgorithmX