Este artículo finalizará la exploración del lenguaje Solidity, y tal como indicamos en la anterior entrega, se ocupará de aspectos avanzados en la escritura de Smart Contracts, para luego considerar el aspecto de Seguridad y finalmente tratar Patrones para ofrecer un código de calidad y minimizar la existencia de errores.
Contratos
Herencia
Solidity soporta la herencia, incluso la múltiple, a través de la palabra clave is. De esta manera, el contrato hijo o derived contract puede utilizar las propiedades y funciones de su contrato padre o base contract, exceptuando aquéllas con visibilidad privada.
El compilador de Solidity, en el fondo lo que hace es copiar el bytecode de los contratos base en el contrato derivado, generando una única dirección de acceso.
pragma solidity >=0.4.24 <0.6.0;
contract PricesChecker { uint16 private min_price = 10; event valueEvent(bool _return); function matcher(uint16 x) internal returns (bool) { if (x >= min_price) { emit valueEvent(true); return true; } else { emit valueEvent(false); return false; } } }
contract Checker is PricesChecker { function pricesMatcher(uint16 x) public returns (bool) { return matcher(x); } }
Respecto a la herencia múltiple, se debe seguir un orden específico, empezando desde los contratos base hasta los contratos más derivados. Esto es lo que se conoce como Method Resolution Order (MRO) o C3 Linearization.
contract A { ... } contract B is A { ... } contract C is A { ... } contract D is A, B, C { ... }
Contratos Abstractos
Son aquellos que tienen al menos una función sin implementar. No existe ninguna palabra reservada para indicar que el contrato es abstracto.
No se puede crear una instancia de un contrato abstracto. Debe ser heredado por un contrato hijo y asegurarse de implementar los métodos que faltan, de otra manera, este contrato también sería un contrato abstracto:
pragma solidity >=0.4.24 <0.6.0;
contract SimpleTransfer { function paymentType() public returns (bytes32); }
contract FastTransfer is SimpleTransfer { function paymentType() public returns (bytes32) { return "URGENT"; } function controlSwift() public pure returns (bytes32) { return "BIC000"; } }
Interfaces
Las interfaces se definen con la palabra reservada interface, y no contienen ninguna implementación de métodos, solamente declaraciones.
En las versiones 0.4.X no podían definirse ni estructuras ni enumeraciones, así como todas las funciones debían ser públicas. En las versiones 0.5.X ya se pueden declarar estructuras y enumeraciones, y las funciones deben declararse como externas.
Lo que sí es común es que no pueden heredar de otras interfaces o contratos, ni declarar variables estado ni un constructor.
Los contratos pueden heredar una o varias interfaces, igual que uno o varios contratos (herencia):
pragma solidity >=0.5.0 <0.6.0;
interface IGreeter { function setGreeter(bytes32 _greeter) external; function getGreeter() external returns (bytes32); }
contract Greeter is IGreeter { bytes32 simpleTitle; function setGreeter(bytes32 _greeter) external { simpleTitle = _greeter; } function getGreeter() external returns (bytes32) { return simpleTitle; } }
Bibliotecas
Solidity proporciona el concepto de bibliotecas, con el objetivo de crear código reutilizable y mantenible. La palabra clave que la define es library.
Una biblioteca es como si fuera un 'singleton' en la EVM. Se trata de código que se puede llamar desde cualquier contrato sin la necesidad de implementarlo nuevamente, o como indica la documentación oficial, 'las bibliotecas pueden verse como contratos base implícitos de los contratos que las usan'. Esto conlleva a ahorrar cantidades de gas y no tener código repetitivo en la cadena de bloques.
Como características propias, no puede tener ningún tipo de almacenamiento, ni funciones de pago ni función de respaldo (fallback):
pragma solidity ^0.5.0;
library Calculator { function add(uint x, uint y) public pure returns (uint z) { return x + y; } }
pragma solidity ^0.5.0;
import "Calculator.sol"; contract User { function addTwoValues(uint a, uint b) public pure returns (uint) { return Calculator.add(a, b); } }
Cuando usamos bibliotecas, no se usa la sintaxis de la herencia, si no que hay que importarla. En la compilación del contrato, en su bytecode, se deja un marcador de posición para la dirección de la biblioteca, que tras compilarla, se reemplaza el marcador con la dirección de la misma. Este proceso es lo que se denomina vinculación de bibliotecas (enlace a nivel de bytecode). Una vez que el contrato está vinculado a la biblioteca, se puede desplegar en la red.
Cuando un contrato llama a una función de una biblioteca, el contexto de la llamada pasa a la biblioteca por lo que cualquier modificación que haga la misma se guardará en el almacenamiento propio del contrato. Dicho de otra manera, es como un puntero, una referencia (no una copia). Esto se consigue con una llamada de bajo nivel llamada delegatecall (se revisa más adelante en este artículo).
El último punto por destacar es que las bibliotecas no tienen registro de log de eventos, pero pueden emitir los eventos del contrato y se considera por tanto evento del contrato, y no evento de la biblioteca (aunque sea ésta la emisora).
Excepciones
Existen excepciones en Solidity, como errores relacionados con el gas, pero no puedes detectarlas. Dichas excepciones desharán todos los cambios realizados en el estado (almacenamiento) por parte de la llamada que ha causado la excepción.
No obstante, puedes activar y desencadenar excepciones manualmente, usando las palabras reservadas require, assert y revert. Existe otra palabra reservada throw que está en desuso y actualmente está eliminada.
La semántica de funcionamiento es la siguiente.
Por ejemplo: Antiguamente usábamos:
if (msg.sender != owner) { throw; }
Ahora podemos utilizar:
if (msg.sender != owner) { revert('You are fake user'); }
o también assert(msg.sender == owner);
o también require(msg.sender == owner, 'You are fake user');
Importante es destacar que tanto require como revert, además de poder agregar un comentario, ante el error retornarán al emisor todo el gas que quede todavía; mientras que assert consumirá todo el gas restante.
El uso se focaliza como sigue:
- require para validar los parámetros de entrada de los usuarios o las condiciones del estado antes de una determinada ejecución.
- assert para comprobaciones de desbordamiento o subdesbordamiento, invariantes y estados internos que nunca deberían suceder (prevenir tales condiciones).
- revert para manejar el mismo tipo de situaciones que require pero con una lógica más compleja.
Ensamblador (bajo nivel)
A veces, programando en Solidity, no es posible escribir el código que deseas de la manera que te gustaría, o simplemente el optimizador del compilador no produce código eficiente. Entonces, en tales casos, puedes incorporar lenguaje ensamblador de la EVM para trabajar a bajo nivel y operar a un acceso 'de grano fino'.
Solidity define un lenguaje ensamblador que puedes usar sin Solidity, o también dentro del código fuente de Solidity (este caso se conoce como inline assembly):
pragma solidity >=0.5.0 <0.6.0;
contract Calculator { function add(uint a, uint b) public pure returns (uint) { assembly { let sum := add(a, b) mstore(0x0, sum) return(0x0, 32) } } }
Para más información de la sintaxis y de los códigos de operación (opcodes) disponibles para programar, te sugiero la documentación oficial disponible aquí.
Seguridad
En este apartado vamos a tratar de diversos puntos a tener en cuenta. Al fin y al cabo Solidity es un lenguaje en continua evolución y madurez, igual que las herramientas que conviven con él. Por ello, siempre es aconsejable estar al día de las novedades y del ecosistema de herramientas del programador porque muchos aspectos quedan obsoletos rápidamente.
Buenas prácticas
- Privacidad. Las variables 'privadas' de Solidity no son privadas en la red blockchain, es decir, solamente es un modificador de visibilidad entre contratos, no para la cadena de bloques: si conoces la transacción puede observar perfectamente los valores de ésta.
- Bucles y límite de gas. Los bucles consumen gas obviamente, por ello, hay que tener cuidado con el número de iteraciones sobre las que actuamos, ya que nos podemos quedar sin gas rápidamente.
- Balance del contrato. Si guardamos el balance del contrato únicamente teniendo en cuenta las funciones con modificador payable estamos en un error. La dirección del contrato desplegado es predecible y quizás previamente se depositó ETH en ella. O se puede utilizar la palabra clave selfdestruct(address) que envía forzosamente los fondos del contrato actual a dicha dirección.
- Aleatoriedad. Realmente no existe ninguna rutina que proporcione números aleatorios en sí mismo (al menos todavía). Hay un método que es utilizar la variable global block.timestamp o now, pero no es un valor aleatorio realmente, ya que el nodo minero puede en cierta medida influir en su valor. El otro método usado es utilizar la variable block.number (también usada para determinar el fin de un período de tiempo), pero igualmente no se puede dar por sentado que el tiempo de generación de bloques sea siempre el mismo. Las investigaciones actuales se inclinan por usar oráculos, algoritmos basados en firmas criptográficas, o utilizar los Smart Contracts de RANDAO.
tx.origin vs. msg.sender
Tanto la propiedad origin del objeto global tx, como la propiedad sender del objeto global msg, contienen un tipo address (dirección).
tx.origin contiene el emisor de la transacción, el origen de toda la cadena de llamadas, mientras que msg.sender posee la dirección origen de la llamada en curso. Es decir, si una transacción se origina hacia un contrato A, y éste contrato llamara a otro contrato B; entonces, desde el B, el valor msg.sender sería la dirección del contrato A, mientras que el valor de tx.origin sería la dirección de quién inició la transacción (el usuario).
Métodos miembro del tipo address
- someAddressPayable.transfer() vs. someAddressPayable.send(). Ambas funciones se usan para transferir fondos de forma segura a la dirección 'someAddressPayable' ya que solamente tienen un estipendio de gas de 2.300. La diferencia radica que transfer se considera una llamada de alto nivel que en caso de error, lanza una excepción, revierte la operación, mientras que send se considera de bajo nivel y solamente devuelve un false en caso de error, por lo que el programador debe comprobar el valor retornado.
- address.call.value()() vs. address.delegatecall() vs address.callcode(). Cuando interactúas con otro Smart Contract y quieres mandarle fondos (o llamar a una función de un contrato), quizás lanzarle 2.300 de gas no es suficiente (el receptor como mucho puede emitir un evento); en tales casos puedes usar address.call.value()() ya que puedes ajustar el gas mandado (en cierta medida es peligroso y sigue siendo una llamada de bajo nivel por lo que si hay una excepción, retorna un booleano). Address.delegatecall() es igual que address.call.value()() pero traslada su ámbito (scope) al contrato que está llamando (es una llamada de bajo nivel -retorna un booleano ante un error-, usada cuando utilizas las bibliotecas), de forma que puede utilizar el contrato llamado, las variables del contrato llamador.
Address.callcode() también es una llamada de bajo nivel, pero ya está desaconsejada y ha desaparecido en las últimas versiones.
Patrones
Por último, en este apartado, mostraremos los patrones más conocidos dentro de Solidity y en extensión en el ámbito de las particularidades de Ethereum y las blockchains afines a ella.
En Solidity como en cualquier lenguaje, nos enfrentamos a problemas que en cierta medida ya se han enfrentado o se están enfrentando otros programadores, de forma que el modelado de la solución puede estar ya resuelto, y si dicho modelo se puede explicar, reutilizar y adaptar, nos encontramos con un patrón de diseño. Los patrones nos ahorran tiempo, y establecen una forma común y estándar de solucionar un tipo concreto de desafío, y lo más importante, nos ayuda a estar seguro que es válido nuestro código.
La siguiente lista constituye un material más que necesario ya que enumera y explica los patrones más utilizados y más recurrentes en el día a día.
A continuación, revisaremos los más conocidos:
- Pull over Push. En proyectos en los que se maneja la transferencia de fondos, en vez de que el emisor esté transfiriendo la suma, es aconsejable que el emisor establezca un método que facilite al destinatario (usuario) retirar los fondos. De esta manera se evita lanzar transferencias a direcciones erróneas o a direcciones desconocidas que podrían ser causantes de errores; otra razón es quedarse sin gas si el emisor realiza varias llamadas en la misma función y por último es una manera de que los usuarios se involucren en el manejo de sus cuentas (incluso se les puede incentivar).
- Checks Effects Interactions (re-entrancy). Los Smart Contracts pueden llamar y utilizar el código de otros contratos. Estas llamadas externas podrían ser 'secuestradas' por los atacantes adueñándose así del flujo de control. Normalmente, el código malicioso ejecuta una función en el contrato vulnerable, realizando operaciones que el desarrollador no espera. Con este patrón se pretende proteger las funciones contra este tipo de ataques de 'reentrada'.
Actualización de contratos: Los contratos desplegados en una cadena de bloques son inmutables. Esto significa que la dirección y el código de un contrato, no pueden modificarse ya que están escritos permanentemente en la cadena de bloques. Por ello han surgido, principalmente, dos maneras creativas para poder usar un nuevo contrato en lugar del anterior (una forma parecida a cómo actualizar un contrato):
- Eternal storage. Es un método que separa la lógica y los datos en diferentes contratos, de forma que el contrato que contiene la lógica puede actualizarse tantas veces como sea necesario, mientras que el contrato que contiene los datos permanece el mismo. Dicho de otra manera, el Usuario interactúa con el Contrato de lógica y este llama al Contrato de datos y las actualizaciones se basan en activar otro Contrato de lógica que seguirá usando el Contrato de datos anterior.
- Proxy delegate. Esta técnica (más compleja que la anterior) evita romper las dependencias de otros contratos (usuarios) que hacen referencia al contrato actualizado, utilizando un contrato que actúa como proxy que delega las llamadas a los contratos correctos (haciendo uso de la llamada de bajo nivel delegatecall que hemos visto en párrafos anteriores). Dicho de otra manera, el Usuario interactúa con el Contrato Proxy que contiene los datos generalmente y mediante la llamada delegatecall utiliza el Contrato de lógica (que maneja los datos del proxy) que podrá ser actualizado.
Zeppelin
Zeppelin Solutions es un actor esencial en el desarrollo de Smart Contracts, ya que ha implementado multitud de propuestas y estándares, siempre con un alto grado de modularidad y seguridad.
Posee dos proyectos interesantísimos, uno es OpenZeppelin, un repositorio open source de contratos y plantillas implementados con las mejores prácticas de seguridad y con tests de pruebas que han sido auditados para eliminar cualquier error o posibilidad de ataques maliciosos.
El otro proyecto es ZeppelinOS, una plataforma para desarrollar y aumentar la productividad en aplicaciones blockchains, incorporando un sistema simple para actualizar contratos.
Aragon
Aragon es la segunda referencia obligada a enseñar en este artículo. Es una comunidad o proyecto fundado para impulsar la creación de DAO (Organizaciones Autónomas Descentralizadas). Posee un conjunto de herramientas muy depuradas para que cualquier empresa o grupo, pueda aprovechar todo el potencial proporcionado por plataforma Ethereum, y orientadas para mejorar las actividades principales de este tipo de organizaciones, como son el financiamiento, la gobernanza o la contabilidad, entre otras.
En su Portal del Desarrollador, nos encontramos su aragonOS, un framework orientado a Smart Contracts que proporciona entre otras características la capacidad de lanzar actualizaciones de los mismos (versionado) y asociarlos a listas de control de acceso.
Conclusiones
Para finalizar simplemente indicar, que el ecosistema de herramientas orientadas a facilitarnos el trabajo con Smart Contracts es poderoso, como las dos anteriores, parecidas a un sistema operativo o CLI. Y como colofón, la existencia de gestores de paquetes (package manager) para administrar y resolver la distribución de código como son aragonPM o ethPM.
Con este artículo completamos esta serie. ¡Síguenos en Twitter para estar al día de próximos posts!