Cómo escribir un contrato inteligente para ICO en 5 minutos



Hola a todos! En este artículo, le diré cómo lanzar un contrato inteligente de recolección de dinero para su ICO en Ethereum en 5 minutos y varios comandos en la terminal. Este ensayo potencialmente le ahorrará decenas de miles de dólares estadounidenses, ya que cualquier programador, y no un programador también, podrá lanzar un contrato inteligente auditado y seguro (en lugar de pagar $ 15,000 - $ 75,000 para el desarrollo). En resumen, puede enviar dinero a este contrato inteligente y recibir tokens ERC20 por él. Se puede decir que este artículo es una recopilación de toda la experiencia que obtuve al lanzar un ICO para mi proyecto.

En Internet, estos suyos ya están llenos de artículos sobre contratos inteligentes, pero tan pronto como comienza a escribir uno, se da cuenta de que la información se repite en todas partes, y simplemente no hay tutoriales sobre cómo engañar a su ERC20, o ya están desactualizados. Por cierto, para que este artículo siga siendo relevante, intentaré indicar los lugares potenciales donde puede quedar obsoleto (y cómo solucionarlo). Vamos!

Solidez


Este es el nombre del idioma principal que el equipo de kéfir desarrolló para lanzar contratos inteligentes. Si usted es un programador, simplemente revise la documentación del lenguaje , es indecentemente simple. Por cierto, lo hicieron simple para que fuera más difícil cometer un error al escribir un contrato inteligente. Así que absolutamente cualquier programador, al menos en el nivel junior, podrá resolverlo. No tiene absolutamente ningún sentido pagar grandes cantidades de dinero a los desarrolladores que conocen la solidez: será un orden de magnitud más barato capacitar a un desarrollador existente.

Contratos inteligentes


... y todo lo que necesitas saber sobre ellos. Omita esta sección si no es un programador. Un contrato inteligente es una pieza de código. En principio, esta es una clase de solidez (OOP, sí), que tiene dos tipos de funciones: cambia de estado y no cambia de estado. Bueno, para ejecutar funciones en un contrato inteligente simplemente enviándole kéfir, debe marcar esta función como payable .

State es un almacén de datos, blockchain, EPT. Los contratos pueden cambiar la cadena de bloques (estado, almacenamiento), pero para cambiar la cadena de bloques debe pagar el kéfir a los mineros. La forma en que compartirán el kéfir no se analizará en el marco de este artículo. El pago a los mineros por ejecutar código de cambio de estado se llama Gas. Si alguien de afuera arroja kéfir a la dirección de un contrato inteligente con una llamada a una función marcada como payable pero no marcada como Constant , View o Pure , entonces la cantidad requerida de kéfir para el pago a los mineros se deducirá de la cantidad enviada. Por lo general, en los tokens ERC20, estas son funciones que entregan el remitente del token para kéfir o transfieren tokens de un titular de tokens a otro.

Y si marca una función en el contrato con las palabras Constant o View (significan lo mismo, solo le permiten leer el estado) o Pure (lo mismo, ni siquiera lee el estado), ¡ni siquiera tendrá que gastar kéfir en la ejecución de esta función! Incluso diré más que estas funciones no necesitan ser llamadas por transacciones, después de todo, cualquier cliente de yogurt puede ejecutarlas teóricamente en casa, y ya nadie necesita saber sobre esto (después de todo, nada está escrito en la cadena de bloques).

Y hay dos cosas importantes en la solidez: herencia múltiple y modificadores de función. También necesitas saber sobre ellos.

El primero: los contratos justos se pueden heredar simultáneamente de varias clases, como TimedCrowdsale , CappedCrowdsale , MintedCrowdsale , Ownable , mientras que las funciones de los constructores también se lanzan una tras otra, pero lo explicaré más adelante como ejemplo.

El segundo es la capacidad de crear funciones que luego se insertarán en otras funciones. Es como una simple encapsulación, solo un poco más flexible: es literalmente una plantilla de función. Cuando crea un modificador, escribe el carácter especial _ donde se refiere al código de una función que usa este modificador. Es decir, los modificadores no son solo funcionalidades encapsuladas que devuelven un valor; Esta es una plantilla de función cuando el código de un modificador se inserta literalmente en una función utilizando este modificador.

Pasemos a practicar.

Ambiente de cocina


Si no sabe qué es Terminal, lea este artículo aquí . Si está en Windows, configure una Terminal a través de WLS. Si ya está familiarizado con la Terminal, continuemos. Además, póngase inmediatamente Node.js: será necesario para los próximos pasos. Es mejor instalar LTS, pero, de hecho, no importa cuál de las versiones modernas del nodo instalar.

Lo primero que instalamos e iniciamos inmediatamente el proceso de sincronización de bloques es geth . En resumen, esta es una utilidad escrita en Go que nos permitirá ejecutar el nodo ether en la computadora local y conectarnos a las redes de prueba y reales. Puede realizar la instalación a través de instaladores , pero le recomiendo que se geth inmediatamente en la Terminal, como se describe aquí . Puede verificar si sus estándares geth están geth ejecutando el comando en la Terminal:

 geth version 

Si te escupió la versión geth: todo está calado, continúa el tutorial. Si no, mal, correcto; parece que tendrá que hacer el amor con la Terminal y su sistema operativo, pero esta no es la primera vez que se da cuenta. Cómo instalar geth, ejecuta el comando en la Terminal:

 geth --testnet console 

Esto iniciará el proceso de sincronización de su nodo con el servidor de prueba, cuyos bloques se pueden ver aquí . Puede verificar si sincronizó con la red en la consola geth :

 eth.blockNumber #  0 —     eth.syncing #     false,     

El proceso de sincronización me llevó de 1 a 4 horas, ¿cuándo? Además, además de la sincronización de bloques, también tendrá que esperar la sincronización de estado, que a menudo es más larga que la sincronización de bloques. También puede usar los geth con el indicador --light - luego la sincronización dura de unos segundos a un minuto y aún puede implementar contratos.

Bien, instalamos la primera utilidad, pon la siguiente. Necesitamos poner un análogo de geth , solo una simulación de blockchain muy local: testrpc . Sí, tenemos 3 blockchains :

  • testrpc - simulación local de blockchain; rápido, pero falso y almacenado solo en su máquina
  • geth --testnet ya es una verdadera cadena de bloques, pero no perderá dinero donde puede obtener kéfir y probar todo geth --testnet gratis
  • geth - mainnet, main, blockchain real, kéfir real; todo de una manera adulta, los errores aquí son las pérdidas de kéfir real

En consecuencia, comenzaremos el contrato de prueba con testrpc , luego lo instalaremos en geth --testnet , y luego lo descargaremos directamente en geth .

Ponemos testrpc ejecutando el siguiente comando:

 npm install -g ethereumjs-testrpc 

Bueno, o se eleva inmediatamente con una trufa, ya que ahora testrpc bajo el ala de la trufa y se llama ganache-cli . Aunque el diablo lo sabe, todo funcionó testrpc con vainilla testrpc . Y si funciona, no lo toques, como me enseñaron en la academia intergaláctica. También puede ejecutarlo para verificar la instalación registrando truffle en la consola, pero el blockchain de prueba ya está sincronizado con nosotros, no lo molestemos.

Bueno, ¿descubriste las cadenas de bloques? ¿Ahora hay nodos y la prueba está incluso sincronizada? Ponemos una práctica utilidad para trabajar con contratos inteligentes en kéfir - truffle , con el siguiente comando:

 npm install -g truffle truffle version #  ,  ,   

Truffle es una herramienta que le permite mantener contratos inteligentes en diferentes archivos, importar otros archivos y también compila su código de contrato inteligente en un código de bytes grande (ilegible por una persona), encuentra automáticamente su geth localmente ejecutable (prueba y real ) o testrpc , implemente su contrato inteligente en esta red. Además, verifica el código de su contrato inteligente en busca de errores y las transacciones recientemente completadas también ayudan a depurar . Masthead, en resumen.

En esta etapa, debería haber instalado: testrpc , geth , testrpc ; si falta algo de esto o la versión no se escupe en la consola a pedido, corríjala; de lo contrario no tendrá éxito.

Además, lancé un script bash simple que instalará todo por ti. Llamado así:

 source <(curl -s https://raw.githubusercontent.com/backmeupplz/eth-installer/master/install.sh) 

- pero aún no lo he probado, así que no estoy seguro de su rendimiento. Sin embargo, me complacerá recibir solicitudes.

Contrato de Figash


Todo ya ha sido inventado y escrito para ti, eso está bien. Un pequeño golpe será todo lo mismo, pero intentaré minimizarlo para ti. Utilizaremos los contratos ERC20 listos para usar de OpenZeppelin : este es ahora el estándar de la industria, han pasado la auditoría y, de hecho, todos usan su código. Muchas gracias por tu contribución al código abierto.

Haga el cd en una carpeta segura y luego escriba:

 mkdir contract && cd contract 

En esta carpeta trabajaremos. Cree un trozo aquí para nuestro contrato inteligente:

 truffle init 

Tropezar, claramente. Ahora tenemos dos carpetas muy importantes en las que escalaremos: contracts y migrations . El primero es el código de nuestros contratos, el segundo es el código de trufa para saber qué hacer al implementar contratos en la cadena de bloques.

A continuación, necesitamos tomar el código de contrato inteligente actual de npm y, de hecho, comenzar el proyecto en sí:

 npm init -y #     ( -y) npm install -E openzeppelin-solidity #       ( -E) 

Bueno, el código de contratos inteligentes de OpenZeppelin está en nuestro bolsillo en la carpeta node_modules/openzeppelin-solidity/contracts . Ahora vamos a la carpeta principal de contracts , eliminamos todos los archivos y agregamos los archivos MyToken.sol y MyCrowdsale.sol ; naturalmente, nombrará sus contratos de manera diferente. El primero será un contrato para nuestro Token ERC20, y el segundo será un contrato de nuestro ICO, que aceptará kéfir y distribuirá MyToken personas. Este artículo puede estar desactualizado, pero siempre puede ver cómo OpenZeppelin sugiere que cree contratos en su repositorio . Así es como se verá MyToken.sol :

 pragma solidity ^0.4.23; // Imports import "../node_modules/openzeppelin-solidity/contracts/token/ERC20/MintableToken.sol"; // Main token smart contract contract MyToken is MintableToken { string public constant name = "My Token"; string public constant symbol = "MTKN"; uint8 public constant decimals = 18; } 

Agradable: ¡tienes un contrato inteligente de tu propio token (solo cambia los nombres en las constantes)! Puedes ver qué MintableToken herencia hay de MintableToken , pero allí todo es lo más simple posible. Este es un token que se puede emitir (del inglés "Mint" - para acuñar), y solo el propietario tiene derecho a emitirlo, ya que el MintableToken también se hereda de Ownable . Además, MintableToken también hereda de clases de tokens ERC20 escritos por OpenZeppelin, en los que se implementa la interfaz ERC20:

 contract ERC20Basic { function totalSupply() public view returns (uint256); function balanceOf(address who) public view returns (uint256); function transfer(address to, uint256 value) public returns (bool); event Transfer(address indexed from, address indexed to, uint256 value); } 

Sí, aquí tienes toda la interfaz ERC20. ¿Es dificil? No lo creo Le da la oportunidad de ver cuántos tokens se emitieron, verificar el saldo de la dirección y transferir los tokens a otra dirección escupiendo un evento de transferencia para clientes de kéfir ligero en la red. Y todo esto lo obtienes gratis en tu MyToken.sol gracias al trabajo de OpenZeppelin: son geniales.

Y ahora pasemos a la parte principal de nuestra ICO: ¡debemos aceptar el kéfir y entregar MyToken ! Así se MyCrowdsale.sol su MyCrowdsale.sol :

 pragma solidity ^0.4.23; // Imports import "../node_modules/openzeppelin-solidity/contracts/crowdsale/emission/MintedCrowdsale.sol"; import "../node_modules/openzeppelin-solidity/contracts/crowdsale/distribution/RefundableCrowdsale.sol"; import "../node_modules/openzeppelin-solidity/contracts/crowdsale/validation/CappedCrowdsale.sol"; import "../node_modules/openzeppelin-solidity/contracts/token/ERC20/MintableToken.sol"; contract MyCrowdsale is CappedCrowdsale, RefundableCrowdsale, MintedCrowdsale { constructor( uint256 _openingTime, uint256 _closingTime, uint256 _rate, address _wallet, uint256 _cap, MintableToken _token, uint256 _goal ) public Crowdsale(_rate, _wallet, _token) CappedCrowdsale(_cap) TimedCrowdsale(_openingTime, _closingTime) RefundableCrowdsale(_goal) { //   ,  ,    // ,     require(_goal <= _cap); } } 

Así que, ¿qué pasa con nosotros? ¿Qué, muchachos, contratos inteligentes? Nuestra venta pública de tokens hereda tres de las propiedades más populares: tiene un límite máximo, que ya no se puede recolectar; tapa blanda, que no recoge los ésteres que se devuelven; momento del comienzo y fin de la venta de tokens. De hecho, ¿qué más se necesita para la felicidad?

Programadores, noten cómo los constructores de múltiples clases de herencia están organizados en una fila y obtienen argumentos del constructor principal de MyCrowdsale . Además, comprobamos que la tecla fija es más alta que la tecla programable: ¡Ales Gut! Además, no se MyCrowdsale parámetros MyCrowdsale en el constructor MyCrowdsale : los pasaremos en la etapa de implementación del contrato en la trufa.

Eso es todo: tiene contratos listos para usar de su propio token ERC20 e incluso un contrato inteligente ICO, que se configura de acuerdo con su deseo y entrega sus tokens para kéfir. Además, es compatible con todas las billeteras ERC20, ¡un error! Pasemos a las pruebas manuales y la implementación.

Migraciones


Como dije antes, probaremos secuencialmente en tres redes blockchain, pero el proceso de prueba con bolígrafos siempre será el mismo. Comencemos con testrpc , luego pasemos a geth --testnet y continuemos con geth . Faros de Sou, acabamos de escribir el código, intentemos compilarlo. En la carpeta del proyecto, escriba:

 truffle compile 

Si todo se compiló sin problemas, verá la build , que contendrá el krakozyab para la trufa para que pueda incrustar el código de bytes de sus contratos inteligentes en la cadena de bloques. Antes de implementar contratos inteligentes, debemos decirle a la trufa qué hacer en absoluto. El despliegue de trufas de los contratos inteligentes se llama migración, bueno, sigamos con esta terminología. Vaya a migrations/1_initial_migration.js y cámbielo de la siguiente manera:

 const token = artifacts.require("../contracts/MyToken.sol"); const crowdsale = artifacts.require("../contracts/MyCrowdsale.sol"); module.exports = function(deployer, network, accounts) { const openingTime = 1514764800; // 15  2018 const closingTime = 1561939200; // 1  2019 const rate = new web3.BigNumber(1); // 1   1  const wallet = '0x281055afc982d96fab65b3a49cac8b878184cb16'; // - const cap = 200 * 1000000; //  const goal = 100 * 1000000; //  return deployer .then(() => { return deployer.deploy(token); }) .then(() => { return deployer.deploy( crowdsale, openingTime, closingTime, rate, wallet, cap, token.address, goal ); }) .then(() => { // Crowdsale    var tokenContract = web3.eth.contract(token.abi).at(token.address); web3.eth.defaultAccount = web3.eth.accounts[0]; tokenContract.transferOwnership(crowdsale.address); }); }; 

Este es el mismo archivo que utilizará la trufa para desplegar contratos. Entonces, ¿qué estamos haciendo aquí? Primero, solicitamos MyToken y MyCrowdsale compilados. Después, establecemos las constantes con todos los argumentos de nuestro ICO: establecemos las horas de inicio y finalización; cuántas fichas recibirán las personas por 1 vey de kéfir (0.000000000000000001 eth = 1 wei; establecer decimals indica cuántas órdenes se necesitan para obtener 1 de sus fichas recién hechas); billetera, donde vendrá el kéfir obtenido de la venta; tapa dura y tapa blanda. Tenga en cuenta que el tiempo de openingTime siempre debe ser posterior al momento del bloqueo actual en la cadena de bloques; de lo contrario, su contrato inteligente no se bloqueará debido a la verificación de la condición en TimedCrowdsale . Pisé este rastrillo, y las transacciones fallidas no se pueden debitar en absoluto. Cambie estas constantes como desee.

El siguiente paso es precisamente el despliegue de contratos inteligentes. Nada interesante aquí: tenemos un objeto de despliegue que despliega artefactos de contratos inteligentes y pasa argumentos allí. Tenga en cuenta que MyToken se MyToken primero, y solo luego MyCrowdsale , y la dirección del primero se pasa en el segundo como argumento.

Entonces, lo más interesante es sobre lo que no escriben ni en la documentación ni en los libros. Cuando crea un MyToken desde una billetera, esta billetera se convierte en el propietario de MyToken en la superclase Ownable ; lo mismo ocurre con MyCrowdsale . Si profundizas en el MintableToken , puedes ver que solo el Owner puede acuñar monedas. ¿Y quién es el dueño de MyToken ? Así es: la dirección que lo molestó. ¿Y quién enviará solicitudes para acuñar monedas? Correcto: contrato inteligente MyCrowdsale . Permítame recordarle que la dirección que creó MyToken y MyCrowdsale son dos direcciones diferentes.

Por lo tanto, estamos agregando el tercer paso de implementación no ortodoxo, donde la dirección que ha desafiado los contratos ( web3.eth.accounts[0] ) llama a la función transferOwnership en el contrato MyToken que MyCrowdsale propietario de MyToken y pueda acuñar monedas. Y MyCrowdsale todavía está bajo la propiedad de web3.eth.accounts[0] , por lo que todo está incluido.

Nota sobre web3.eth.accounts[0] : cuando implemente un contrato inteligente, asegúrese de que geth o testrpc tengan la billetera correcta en web3.eth.accounts[0] : no pierda la clave privada, aunque esto no le hace daño, pero de repente el dueño tendrá que hacer algo más tarde, pero ¿la clave ya no está?
En testrpc , como regla, las cuentas se crean inmediatamente al inicio y se desbloquean de inmediato; sin embargo, en una prueba y en una cadena de bloques de aire real, vale la pena crear una cuenta a través de personal.newAccount() , luego reponga esta dirección a través de Faucet en la cadena de bloques de prueba o kéfir real en la cadena de bloques real. No pierdas tu contraseña y claves privadas.
Además, puede agregar una billetera existente a sus cuentas llamando a web3.personal.importRawKey('pvt_key', 'password') , pero para esto necesita llamar a geth con el parámetro adicional --rpcapi="db,eth,net,web3,personal,web3" . Creo que lo resolverás.

Pruebas e implementación


Sí, los contratos están listos, las migraciones están escritas, solo queda desplegar y verificar. Tanto geth (test y real) como testrpc administran de la misma manera a través de la truffle console , por lo que describiré el método de verificación para testrpc y simplemente le diré cómo habilitar geth después. Y así, lanzamos la prueba blockchain de kéfir local:

 testrpc 

Um ... eso es todo. Simula la cadena de bloques de kéfir localmente.

Y para implementar en la cadena de prueba ether blockchain, en lugar de este comando, obtendrá geth --testnet --rpc . Y para implementar en la cadena de bloques real de ether, simplemente geth --rpc . La bandera --rpc necesaria para que la trufa pueda conectarse. Los siguientes pasos de implementación y prueba son más o menos los mismos para los tres tipos de blockchain. Lo único es que después de ejecutar la prueba o la cadena de bloques real a través de geth , comenzará a sincronizar los bloques, y esto puede demorar hasta 4-5 horas en una buena conexión a Internet. Una observación sobre esto fue al comienzo del artículo. Antes de implementar contratos inteligentes, recomiendo esperar la sincronización completa. Además, la cadena de bloques pesa en la región de 60-100 gigabytes, así que prepare el espacio en disco para esto.
Además, también asegúrese de que web3.eth.accounts[0] desbloqueado. Por lo general, puede registrar testrpc en la consola, que se abre inmediatamente, o en una ventana de Terminal separada en la consola, que se abre a través de geth console : eth.unlockAccount(eth.accounts[0], ", ", 24*3600) - esto desbloqueará su cuenta, lo que debería crear un contrato inteligente

Ahora abra una nueva ventana de Terminal (no testrpc , debería funcionar) y escríbala en la carpeta del proyecto:

 truffle migrate --reset 

Este comando mágico compilará un contrato inteligente (es decir, no necesita escribir una truffle compile cada vez) y lo implementará en el micro servidor blockchain que se encuentra abierto localmente. Vale la pena señalar que si testrpc hace esto instantáneamente, entonces la prueba y las cadenas de bloques reales incluirán la transacción en los siguientes bloques mucho más tiempo. Después de eso, debes escupir algo como esto en la consola:

 Using network 'development'. Running migration: 1_initial_migration.js Running step... Replacing MyToken... ... 0x86a7090b0a279f8befc95b38fa8bee6918df30928dda0a3c48416454e2082b65 MyToken: 0x2dc35f255e56f06bd2935f5a49a0033548d85477 Replacing MyCrowdsale... ... 0xf0aab5d550f363478ac426dc2aff570302a576282c6c2c4e91205a7a3dea5d72 MyCrowdsale: 0xaac611907f12d5ebe89648d6459c1c81eca78151 ... 0x459303aa0b79be2dc2c8041dd48493f2d0e109fac19588f50c0ac664f34c7e30 Saving artifacts... 

Creo que ya se dio cuenta de que la consola le dio las direcciones de los contratos inteligentes MyToken y MyCrowdsale . Eso es todo! El contrato inteligente está incrustado en la cadena de bloques cuyo micro servidor ha abierto. Solo queda verificar que los tokens se distribuyan realmente a los usuarios que envían kéfir al contrato inteligente MyCrowdsale . Escribimos lo siguiente en la Terminal para ingresar a la consola de trufa:

 truffle console 

Escribimos lo siguiente en la trufa ahora (sin comentarios solamente):

 //   - t="0x2dc35f255e56f06bd2935f5a49a0033548d85477" //     MyToken ="0xaac611907f12d5ebe89648d6459c1c81eca78151" //     MyCrowdsale //   - token=MyToken.at(t) crowdsale=MyCrowdsale.at(c) //       account=web3.eth.accounts[0] // ,      token.balanceOf(account) //   0 //    - web3.eth.sendTransaction({from: account, to:c, value: web3.toWei(0.1, 'ether'), gas: 900000}) 

En el caso de, testrpcpuede verificar inmediatamente el saldo de nuestra billetera nuevamente, pero en el caso de la prueba y la cadena de bloques real, debe esperar hasta que nuestra transacción se incluya en el bloque, generalmente cuando esto sucede, la trufa le da el número de transacción. Has esperado? Verifique nuevamente nuestro saldo en MyToken:

 // ,      token.balanceOf(account) //     

Eso es todo!Primero pruebe su contrato testrpc, luego geth --testnet, luego despliegue geth. ¡Así que lanzaste tu propio ICO! Y no tenía que gastar decenas de kilobaks para auditoría y lanzamiento. Enredar con lo que los chicos de OpenZeppelin nos proporcionaron es realmente muy difícil. Y cuando lo usas truffle, así es como el desarrollo solidario generalmente se convierte en un cuento de hadas. Bueno, excepto en los casos en que las transacciones se invierten durante la ejecución de un contrato inteligente, debuta en el infierno. Pero la depuración de los contratos inteligentes es realmente digna de un artículo separado.

Conclusión


¡Muchas gracias por leer hasta el final de este artículo! Si logré ahorrarle tiempo o dinero, o si aprendió algo nuevo de este artículo, entonces me alegraré mucho. También le agradecería que comparta este artículo con sus amigos o conocidos que quieran llevar a cabo una ICO: ahorre $ 75,000 para los subprogramadores que extraen dinero del mercado criptográfico como parásitos, copiando las mismas 25 líneas de código .

¡Buena suerte en el desarrollo de contratos inteligentes! ¿Aún tienes preguntas? Te pregunto en los comentarios. Estaré encantado de responder todo y tratar de ayudar con los problemas.

Bono


Pero, ¿qué sucede si desea cambiar la lógica por la cual se considera el precio de compra de los tokens? Por supuesto, puede cambiarlo correctamente rateo usar una de las clases de contratos de OpenZeppelin, pero ¿qué pasa si quiere algo aún más pervertido? En un contrato inteligente, puede anular la función de la getTokenAmountsiguiente manera:

 function _getTokenAmount(uint256 _weiAmount) internal view returns (uint256) { if (block.timestamp < 1533081600) { // August 1st, 2018 rate = rate * 4; } else if (block.timestamp < 1546300800) { // January 1st, 2019 rate = rate * 2; } return _weiAmount.mul(rate); } 

En general, esto puede hacer que el precio de la ficha dependa del momento de la compra: cuanto más se adentre en el bosque, más caras serán las fichas. No tenga miedo de experimentar y reescribir algunas de las características de los contratos inteligentes: ¡es divertido!

Source: https://habr.com/ru/post/es414493/


All Articles