Capítulo 4. Nuestro primer contrato inteligente
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
Ahora que lo tenemos todo instalado, es hora de construir nuestro primer contrato. Siguiendo la tradición de los libros de introducción a la programación, nuestro primer programa nos saludará con un "¡Hola, mundo!".
Al desarrollar este programa, vamos a aprender a utilizar las herramientas que nos proporciona Truffle para crear y probar nuestra aplicación. También empezaremos a explorar el lenguaje Solidity, incluyendo un vistazo a las funciones y variables de estado.
Nuestro objetivo en este capítulo es encontrar un ritmo en la forma de construir nuestra aplicación y descubrir si vamos por buen camino. Para ayudarnos en ello, adoptaremos el desarrollo dirigido por pruebas (TDD) para ese bucle de retroalimentación instantánea.
Empecemos por configurar nuestro proyecto.
Configurar
Mientras nos instalamos, necesitaremos un directorio para alojar nuestra nueva aplicación. Creemos primero un directorio llamado greeter y cambiemos a nuestro nuevo directorio. Abre tu terminal y utiliza los siguientes comandos:
$
mkdir greeter$
cd
greeter
Ahora vamos a inicializar un nuevo proyecto Truffle de la siguiente manera:
$
truffle init
Este comando generará la siguiente salida:
✔ Preparing to download
✔ Downloading
✔ Cleaning up temporary files
✔ Setting up box
Unbox successful. Sweet!
Commands:
Compile: truffle compile
Migrate: truffle migrate
Test contracts: truffle test
Nuestro directorio greeter debería incluir ahora los siguientes archivos:
greeter ├── contracts │ └── Migrations.sol ├── migrations │ └── 1_initial_migration.js ├── test └── truffle-config.js
Observa que los comandos especificados en la salida se alinean bien con la estructura de directorios generada al inicializar nuestra aplicación. truffle compile
compilará todos los contratos en el directorio contracts, truffle migrate
desplegará nuestros contratos compilados ejecutando los scripts en nuestro directorio migrations y, por último, truffle test
ejecutará las pruebas en nuestro directorio test.
El último elemento creado para ti es el archivo truffle-config.js. Aquí es donde colocaremos las configuraciones específicas de nuestra aplicación.
Ahora que tenemos nuestra estructura inicial, estamos listos para empezar el desarrollo.
Nuestra primera prueba
A medida que implementemos funciones para nuestros contratos, utilizaremos TDD para aprovechar el breve bucle de retroalimentación que proporciona. Si no estás familiarizado con TDD, es una forma de escribir software en la que primero empezamos con una prueba que falla y luego escribimos el código necesario para que la prueba pase. Una vez que todo funciona, podemos refactorizar el código para hacerlo más fácil de mantener.
El soporte de pruebas que proporciona Truffle es una de las áreas en las que el cinturón de herramientas brilla de verdad. Ofrece soporte de pruebas tanto en JavaScript como en Solidity; para nuestros ejemplos, utilizaremos JavaScript, ya que su adopción está mucho más extendida, lo que facilita la búsqueda de recursos adicionales en caso de que te quedes atascado. Si quieres explorar la escritura de pruebas en Solidity, puedes consultar la documentación de pruebas de Truffle.
Nuestra primera prueba en el Ejemplo 4-1 va a consistir en asegurarnos de que nuestro contrato vacío puede desplegarse correctamente. Esto puede parecer innecesario, pero los errores que experimentaremos al conseguir que esto pase proporcionan una gran manera de ver algunos de los errores que probablemente nos encontremos en nuestra carrera.
En el directorio de pruebas, crea un archivo llamado greeter_test.js:
$
touchtest
/greeter_test.js
A continuación, añade el código de prueba, como en el Ejemplo 4-1.
Ejemplo 4-1. Prueba de que nuestro contrato puede desplegarse
const
GreeterContract
=
artifacts
.
require
(
"Greeter"
)
;
contract
(
"Greeter"
,
(
)
=>
{
it
(
"has been deployed successfully"
,
async
(
)
=>
{
const
greeter
=
await
GreeterContract
.
deployed
(
)
;
assert
(
greeter
,
"contract was not deployed"
)
;
}
)
;
}
)
;
Truffle proporciona una forma de cargar e interactuar con contratos que han sido compilados a través de la función
artifacts.require
. Aquí, pasarás el nombre del contrato, no el nombre del archivo, ya que un archivo puede contener varias declaraciones de contrato.Las pruebas de Truffle utilizan Mocha, pero con un giro. La función
contract
actuará de forma similar a la incorporada endescribe
, pero con la ventaja añadida de utilizar la función de sala limpia de Truffle. Esta función significa que los contratos nuevos se implementarán antes de que se ejecuten las pruebas anidadas en ellos. Esto ayuda a evitar que se comparta estado entre distintos grupos de pruebas.Todas las interacciones con la cadena de bloques van a ser asíncronas, así que en lugar de utilizar Promesas y el método
Promise.prototype.then
, aprovecharemos la sintaxisasíncrona/de espera ahora disponible en JavaScript.
Al ejecutar nuestras pruebas, recibiremos un error parecido a éste:
$
truffletest
Compiling your contracts...
===========================
>
Compiling ./contracts/Migrations.solError: Could not find artifacts for Greeter from any sources
at Resolver.require (/usr/local/lib/node_modules/truffle/build/webpack:...
at TestResolver.require (/usr/local/lib/node_modules/truffle/build/...
at Object.require (/usr/local/lib/node_modules/truffle/build/webpack:...
...omitted..
Truffle v5.0.31 (core: 5.0.31)
Node v12.8.0
Esto nos proporciona información procesable. El error nos dice que después de compilar nuestros contratos, Truffle no pudo encontrar uno llamado Greeter. Puesto que aún no hemos creado este contrato, este error es perfectamente razonable. Sin embargo, si ya has creado un contrato y sigues obteniendo este error, es probable que se deba a un error tipográfico en la declaración del contrato que se encuentra en el archivo Solidity o en la declaración artifacts.require
.
Vamos a crear el archivo Greeter y a añadir el código del Ejemplo 4-2 para ver qué información nos proporcionará la ejecución de nuestro conjunto de pruebas.
En el terminal, crea el archivo Greeter:
$
touch contracts/Greeter.sol
La línea
pragma
es una instrucción del compilador. Aquí le decimos al compilador de Solidity que nuestro código es compatible con la versión 0.4.0 de Solidity hasta la versión 0.7.0, pero sin incluirla.Los contratos en Solidity son muy similares a las clases en los lenguajes de programación orientados a objetos. Los datos y funciones o métodos definidos dentro de las llaves de apertura y cierre del contrato quedarán aislados en ese contrato.
Una vez realizados estos cambios, ejecutemos de nuevo nuestras pruebas:
$
truffletest
Compiling your contracts...
===========================
>
Compiling ./contracts/Greeter.sol>
Compiling ./contracts/Migrations.solContract: Greeter
1) has been deployed successfully
> No events were emitted
0 passing (34ms)
1 failing
1) Contract: Greeter
has been deployed successfully:
Error: Greeter has not been deployed to detected network...
at Object.checkNetworkArtifactMatch (/usr/local/lib/node_modules/...
at Function.deployed (/usr/local/lib/node_modules/truffle/build/...
at processTicksAndRejections (internal/process/task_queues.js:85:5)
at Context.<anonymous> (test/greeter_test.js:5:21)
El error que aparece aquí indica que nuestro contrato aún no existe en la red; en otras palabras, aún no se ha desplegado. Cada vez que ejecutamos el comando truffle test
, Truffle compila primero nuestros contratos y luego los despliega en una red de prueba. Para desplegar nuestro contrato, tenemos que recurrir a otra herramienta del cinturón de herramientas de Truffle: las migraciones.
Las migraciones son scripts escritos en JavaScript que se utilizan para automatizar la implementación de nuestros contratos. El contrato por defecto Migrations
que se encuentra en contratos/Migraciones.sol es el contrato que se despliega mediante migraciones/1_inicial_migration.js y es actualmente el único contrato que ha llegado a la red de pruebas. Para añadir nuestro contrato Greeter a la red, tendremos que crear una migración utilizando el código que se encuentra en el Ejemplo 4-3.
En primer lugar, tendremos que crear el archivo que contendrá el código de nuestras migraciones:
$
touch migrations/2_deploy_greeter.js
A continuación, podemos añadir el código del Ejemplo 4-3.
Ejemplo 4-3. Implementación del contrato Greeter
const
GreeterContract
=
artifacts
.
require
(
"Greeter"
);
module
.
exports
=
function
(
deployer
)
{
deployer
.
deploy
(
GreeterContract
);
}
Nuestra migración inicial no tiene mucho que hacer. Utilizamos el objeto desplegador proporcionado para desplegar el contrato Greeter. Por ahora, esto es todo lo que tendremos que hacer para que nuestro contrato esté disponible en la red local de pruebas, pero no te preocupes: profundizaremos en las migraciones en el próximo capítulo. Una vez hecho esto, ejecuta las pruebas una vez más:
$
truffletest
Compiling your contracts...
===========================
>
Compiling ./contracts/Greeter.sol>
Compiling ./contracts/Migrations.solContract: Greeter
✓ has been deployed successfully
1 passing (27ms)
¡Éxito! Esta prueba nos permite saber que tenemos todo configurado correctamente y que estamos listos para empezar a implementar funciones.
Decir hola
Este es el punto en el que normalmente utilizaríamos una llamada a alguna variante de printf
o println
para dar salida a nuestro saludo, pero en Solidity no tenemos acceso a la salida estándar, ni al sistema de archivos, ni a la red, ni a ninguna otra entrada/salida (E/S). Lo que sí tenemos son funciones.
Una vez implementado, nuestro contrato inteligente se almacenará en la red Ethereum en una dirección específica. Estará inactivo hasta que le llegue una solicitud pidiéndole que realice algún trabajo y el trabajo que puede hacer nuestro contrato está definido por nuestras funciones. Lo que queremos es una función que pueda decir "¡Hola, mundo!" y, al igual que antes, empezaremos con una prueba.
En test/greeter_test.js, vamos a añadir la prueba del Ejemplo 4-4.
Ejemplo 4-4. Prueba de ¡Hola, mundo!
describe
(
"greet()"
,
()
=>
{
it
(
"returns 'Hello, World!'"
,
async
()
=>
{
const
greeter
=
await
GreeterContract
.
deployed
();
const
expected
=
"Hello, World!"
;
const
actual
=
await
greeter
.
greet
();
assert
.
equal
(
actual
,
expected
,
"greeted with 'Hello, World!'"
);
});
});
En este caso, establecemos un valor esperado y, a continuación, recuperamos el valor de nuestro contrato y comprobamos si son iguales. Tenemos que marcar la función de prueba como async
, ya que vamos a realizar una llamada a nuestra blockchain de prueba local para interactuar con este contrato.
Al ejecutar la prueba se obtiene lo siguiente:
$
truffletest
Compiling your contracts...
===========================
>
Compiling ./contracts/Greeter.sol>
Compiling ./contracts/Migrations.solContract: Greeter
✓ has been deployed successfully
greet()
1) returns 'Hello, World!'
> No events were emitted
1 passing (42ms)
1 failing
1) Contract: Greeter
greet()
returns 'Hello, World!':
TypeError: greeter.greet is not a function
at Context.<anonymous> (test/greeter_test.js:13:36)
at processTicksAndRejections (internal/process/task_queues.js:85:5)
Cuando nos centramos en el error, vemos que greeter.greet
no es una función. Es hora de añadir una función a nuestro contrato. Actualiza el contrato Greeter con la función del Ejemplo 4-5.
Ejemplo 4-5. Añadir la función saludar a Greeter
pragma solidity
>=
0
.
4
.
0
<
0
.
7
.
0
;
contract
Greeter
{
function
greet
()
external
pure
returns
(
string
memory
)
{
return
"Hello, World!"
;
}
}
Aquí creamos una función con el identificador o nombre greet
, que no toma ningún parámetro. Tras el identificador, indicamos que nuestra función es una función external
. Esto significa que forma parte de la interfaz de nuestro contrato y que se puede llamar desde otros contratos, o desde transacciones, pero no se puede llamar desde dentro del contrato o, al menos, no sin una referencia explícita al objeto sobre el que se llama. Nuestras otras opciones aquí son public
, internal
, y private
.
public
Las funciones también forman parte de la interfaz, lo que significa que pueden invocarse desde otros contratos o transacciones, pero además pueden invocarse internamente. Esto significa que puedes utilizar un receptor implícito del mensaje al invocar el método dentro de un método.
internal
y las funciones private
deben utilizar el receptor implícito o, en otras palabras, no pueden llamarse a un objeto ni a this
. La principal diferencia entre estos dos modificadores es que las funciones private
sólo son visibles dentro del contrato en el que están definidas, y no en los contratos derivados.
Las funciones que no alterarán el estado de las variables del contrato pueden marcarse como pure
o view
. Las funciones pure
no leen de la cadena de bloques. En su lugar, operan con los datos que se les han pasado o, como en nuestro caso, con datos que no necesitaban ninguna entrada en absoluto. Las funciones view
pueden leer datos de la cadena de bloques, pero de nuevo están restringidas en el sentido de que no pueden escribir en la cadena de bloques.
Tras nuestra declaración de que esta función es pure
, identificamos lo que esperamos que devuelva nuestra función. Solidity permite múltiples valores de retorno, pero en nuestro caso sólo devolveremos un valor: el tipo string
. También indicamos que se trata de un valor que no hace referencia a nada situado en el almacenamiento persistente de nuestro contrato utilizando la palabra clave memory
.
El cuerpo de nuestra función devuelve la cadena que buscamos: "¡Hola, mundo!". Esto debería satisfacer los requisitos de nuestra prueba. Pero no te fíes de nuestra palabra: vuelve a ejecutar las pruebas para comprobarlo:
$
truffletest
Compiling your contracts...
===========================
>
Compiling ./contracts/Greeter.sol>
Compiling ./contracts/Migrations.solContract: Greeter
✓ has been deployed successfully
greet()
✓ returns 'Hello, World!' (51ms)
2 passing (82ms)
Una vez superada esta prueba, pasemos a hacer nuestro contrato un poco más flexible dando a nuestros usuarios la posibilidad de cambiar el saludo.
Dinamizar nuestro contrato
Ahora que nuestro contrato ha devuelto un valor codificado, continuemos y hagamos que el saludo sea dinámico. Para ello, tenemos que añadir otra función que nos permita establecer el mensaje que devolverá nuestra función greet()
.
Antes hemos mencionado la función "sala limpia" al utilizar la función contract
en nuestras pruebas. Esta función desplegará nuevas instancias de nuestros contratos para su uso dentro de la función de devolución de llamada de ese bloque de código. En la prueba que estamos a punto de escribir, queremos asegurarnos de que nuestros cambios de estado permanecen aislados del resto de las pruebas, para que no nos encontremos en una situación en la que el orden de nuestras pruebas afecte al éxito o al fracaso de nuestro conjunto de pruebas. Para ello, crearemos otro bloque contract
en nuestro archivo test/greeter-test.js, como se ilustra en el Ejemplo 4-6.
Ejemplo 4-6. Probar que el saludo puede hacerse dinámico
const
GreeterContract
=
artifacts
.
require
(
"Greeter"
);
contract
(
"Greeter"
,
()
=>
{
it
(
"has been deployed successfully"
,
async
()
=>
{
const
greeter
=
await
GreeterContract
.
deployed
();
assert
(
greeter
,
"contract failed to deploy"
);
});
describe
(
"greet()"
,
()
=>
{
it
(
"returns 'Hello, World!'"
,
async
()
=>
{
const
greeter
=
await
GreeterContract
.
deployed
();
const
expected
=
"Hello, World!"
;
const
actual
=
await
greeter
.
greet
();
assert
.
equal
(
actual
,
expected
,
"greeted with 'Hello, World!'"
);
});
});
});
contract
(
"Greeter: update greeting"
,
()
=>
{
describe
(
"setGreeting(string)"
,
()
=>
{
it
(
"sets greeting to passed in string"
,
async
()
=>
{
const
greeter
=
await
GreeterContract
.
deployed
()
const
expected
=
"Hi there!"
;
await
greeter
.
setGreeting
(
expected
);
const
actual
=
await
greeter
.
greet
();
assert
.
equal
(
actual
,
expected
,
"greeting was not updated"
);
});
});
});
Reconocerás que la configuración es muy parecida a la de nuestra prueba anterior. Establecemos una variable para que contenga nuestro valor de retorno esperado, que es la cadena que también pasaremos a la función setGreeting
. A continuación, actualizamos el saludo y pedimos al greet
su valor de retorno. Ambas son llamadas asíncronas que nos obligan a utilizar la palabra clave await
. Por último, comprobamos el valor de greet
con nuestro valor esperado.
Al ejecutar las pruebas, obtenemos la siguiente salida:
$
truffletest
Compiling your contracts...
===========================
>
Compiling ./contracts/Greeter.sol>
Compiling ./contracts/Migrations.solContract: Greeter
✓ has been deployed successfully
greet()
✓ returns 'Hello, World!' (48ms)
Contract: Greeter: update greeting
setGreeting(string)
1) sets greeting to passed in string
> No events were emitted
2 passing (111ms)
1 failing
1) Contract: Greeter: update greeting
setGreeting(string)
sets greeting to passed in string:
TypeError: greeter.setGreeting is not a function
at Context.<anonymous> (test/greeter_test.js:26:21)
at processTicksAndRejections (internal/process/task_queues.js:85:5)
Nuestras pruebas indican que la función setGreeting
aún no existe; añadamos esta función a nuestro contrato. De vuelta en nuestro archivo contratos/Greeter.sol, después de nuestra función greet
, añade la firma de función del Ejemplo 4-7.
Ejemplo 4-7. Añadir setGreeting() a Greeter
function
setGreeting
(
string
calldata
greeting
)
external
{
}
Nuestra función setGreeting
pretende actualizar el estado de nuestro contrato con un nuevo saludo, lo que significa que necesitamos aceptar un parámetro para este nuevo valor. Se espera que este nuevo valor sea una cadena, y será referido por el identificador greeting
. Al igual que nuestra función greet
, esta función está pensada para ser llamada desde scripts externos u otros contratos y no se hará referencia a ella internamente.
Dado que esta función se llama desde el mundo exterior, los datos que se pasan como parámetro no forman parte del almacenamiento persistente del contrato, sino que se incluyen como parte de los datos de llamada y deben etiquetarse con la ubicación de datos calldata
. La ubicación calldata
sólo es necesaria cuando la función se declara como externa y cuando el tipo de datos del parámetro es un tipo de referencia, como un mapping, struct, string o array. Los tipos de valor como int
o address
no requieren esta etiqueta.
Con la función declarada, si ahora ejecutamos nuestras pruebas, obtendremos la siguiente salida:
$
truffletest
Compiling your contracts...
===========================
>
Compiling ./contracts/Greeter.sol>
Compiling ./contracts/Migrations.solContract: Greeter
✓ has been deployed successfully
greet()
✓ returns 'Hello, World!' (45ms)
Contract: Greeter: update greeting
setGreeting(string)
1) sets greeting to passed in string
> No events were emitted
2 passing (215ms)
1 failing
1) Contract: Greeter: update greeting
setGreeting(string)
sets greeting to passed in string:
greeting was not updated
+ expected - actual
-Hello, World!
+Hi there!
at Context.<anonymous> (test/greeter_test.js:29:14)
at processTicksAndRejections (internal/process/task_queues.js:85:5)
Revisando el fallo de la prueba, nos enteramos de que el valor devuelto por la función greet
no era el esperado. Nuestra función aún no hace nada y, por tanto, el saludo nunca cambió.
Para actualizar una variable en una función y que esa variable esté disponible en otra función, tendremos que almacenar los datos en el almacenamiento persistente del contrato utilizando una variable de estado, como se demuestra en el Ejemplo 4-8.
En nuestro archivo contracts/Greeter.sol, añade el código del Ejemplo 4-8 al contrato Greeter.
Ejemplo 4-8. Añadir variable de estado al contrato Greeter
pragma solidity
>=
0
.
4
.
0
<
0
.
7
.
0
;
contract
Greeter
{
string
private
_greeting
;
function
greet
()
external
pure
returns
(
string
memory
)
{
return
"Hello, World!"
;
}
function
setGreeting
(
string
calldata
greeting
)
external
{
}
}
Las variables de estado estarán disponibles para todas las funciones definidas dentro de un contrato, de forma similar a las variables de instancia o variables miembro de otros lenguajes orientados a objetos. También son donde almacenaremos los datos que existirán durante toda la vida de nuestro contrato. Al igual que las funciones, las variables de estado pueden declararse con distintos niveles de modificadores de visibilidad, como public
, internal
y private
. En nuestro ejemplo anterior, utilizamos el modificador private
lo que significa que esta variable es accesible en nuestro contrato Greeter.
Nota
Todos los datos de la cadena de bloques son visibles públicamente desde el mundo exterior. Los modificadores de variables de estado sólo restringen cómo se puede interactuar con los datos desde dentro del contrato o desde otros contratos.
Cuando escribimos una función que actualiza una variable de estado, como va a hacer nuestro setGreeting
, no podemos nombrar el parámetro igual que nuestra variable de estado. Para evitar este tipo de conflictos, es práctica común anteponer un guión bajo (_) a los nombres de las variables de estado o de los parámetros.
Actualicemos la función setGreeting
para establecer la variable de estado, como se muestra en el Ejemplo 4-9.
Ejemplo 4-9. Actualizar variable de estado
function
setGreeting
(
string
calldata
greeting
)
external
{
_greeting
=
greeting
;
}
Incluso con este cambio, nuestra prueba seguiría fallando. Lo que queremos hacer ahora es actualizar la función greet
para que lea de esta variable de estado, pero hay algunas cosas que debemos tener en cuenta antes de hacer este cambio. La primera es que nuestra función está marcada actualmente como pure
. Tendremos que actualizar la función para que sea una función view
ya que ahora vamos a acceder a datos almacenados en la cadena de bloques. Una vez que pasemos a leer de la variable de estado en nuestra función greet
, ya no tendremos nuestro saludo por defecto y la prueba inicial fallará. Para solucionar estos problemas, daremos a greeting
un saludo por defecto de "¡Hola, mundo!". También cambiaremos la función de pure
a view
y actualizaremos el valor de retorno para que utilice el valor almacenado en greeting
. El Ejemplo 4-10 muestra nuestro contrato con todos estos cambios realizados.
Ejemplo 4-10. Lectura de nuestra variable de estado
pragma solidity
>=
0
.
4
.
0
<
0
.
7
.
0
;
contract
Greeter
{
string
private
_greeting
=
"Hello, World!"
;
function
greet
()
external
view
returns
(
string
memory
)
{
return
_greeting
;
}
function
setGreeting
(
string
calldata
greeting
)
external
{
_greeting
=
greeting
;
}
}
Después de ejecutar nuestras pruebas, ¡veremos que las tres pasan!
$
truffletest
Compiling your contracts...
===========================
>
Compiling ./contracts/Greeter.sol>
Compiling ./contracts/Migrations.solContract: Greeter
✓ has been deployed successfully
greet()
✓ returns 'Hello, World!' (57ms)
Contract: Greeter: update greeting
setGreeting(string)
✓ sets greeting to passed in string (116ms)
3 passing (224ms)
Cómo hacer que el Greeter sea propio
Tal y como está ahora, cualquiera puede cambiar el mensaje de nuestro contrato de Bienvenida. Esto puede estar bien en algunos casos, pero también podría llevar a que alguien cambiara el mensaje por algo menos acogedor. Para evitarlo, ahora añadiremos la idea de propiedad al contrato, y luego restringiremos la capacidad de cambiar el saludo al propietario.
Para ello, queremos establecer el propietario del contrato Greeter en la dirección que desplegó el contrato. Esto significa que tendremos que almacenar la dirección durante la inicialización, y para ello, tendremos que escribir una función constructora. También necesitaremos acceder a cierta información del objeto msg
. El objeto msg
está disponible globalmente e incluye los datos de la llamada, el remitente del mensaje, la firma de la función a la que se llama y el valor (cuánto wei se ha enviado).
Nuestra primera prueba va a afirmar que existe un propietario invocando una función getter de owner
. Como esto no depende del cambio de estado de nada, pondremos esta prueba en el bloque inicial de pruebas Greeter, como se muestra en el Ejemplo 4-11.
Ejemplo 4-11. Comprobar que existe un propietario
const
GreeterContract
=
artifacts
.
require
(
"Greeter"
);
contract
(
"Greeter"
,
()
=>
{
it
(
"has been deployed successfully"
,
async
()
=>
{
const
greeter
=
await
GreeterContract
.
deployed
();
assert
(
greeter
,
"contract failed to deploy"
);
});
describe
(
"greet()"
,
()
=>
{
it
(
"returns 'Hello, World!'"
,
async
()
=>
{
const
greeter
=
await
GreeterContract
.
deployed
();
const
expected
=
"Hello, World!"
;
const
actual
=
await
greeter
.
greet
();
assert
.
equal
(
actual
,
expected
,
"greeted with 'Hello, World!'"
);
})
});
describe
(
"owner()"
,
()
=>
{
it
(
"returns the address of the owner"
,
async
()
=>
{
const
greeter
=
await
GreeterContract
.
deployed
();
const
owner
=
await
greeter
.
owner
();
assert
(
owner
,
"the current owner"
);
});
});
})
La ejecución de esta prueba generará el siguiente fallo:
$
truffletest
Compiling your contracts...
===========================
>
Compiling ./contracts/Greeter.sol>
Compiling ./contracts/Migrations.solContract: Greeter
✓ has been deployed successfully
greet()
✓ returns 'Hello, World!' (54ms)
owner()
1) returns the address of the owner
> No events were emitted
Contract: Greeter: update greeting
setGreeting(string)
✓ sets greeting to passed in string (274ms)
3 passing (409ms)
1 failing
1) Contract: Greeter
owner()
returns the address of the owner:
TypeError: greeter.owner is not a function
at Context.<anonymous> (test/greeter_test.js:22:35)
at processTicksAndRejections (internal/process/task_queues.js:85:5)
Este fallo me resulta familiar. No tenemos una función definida en nuestro contrato Greeter llamada owner
. Como se trata de una función getter, tendremos que añadir una variable de estado que contenga la dirección del propietario, y luego nuestra función deberá devolver esa dirección. El lenguaje Solidity proporciona dos tipos address
: uno es address
y el otro es address payable
. La diferencia entre ellos es que address payable
da acceso a los métodos transfer
y send
, y las variables de este tipo también pueden recibir éter. No vamos a enviar éter a esta dirección y podemos utilizar el tipo address
para nuestros fines.
Sigamos adelante y actualicemos el contrato Greeter con estos cambios, como se ilustra en el Ejemplo 4-12.
Ejemplo 4-12. Añadir la variable de estado de propiedad y la función getter
pragma solidity
>=
0
.
4
.
0
<
0
.
7
.
0
;
contract
Greeter
{
string
private
_greeting
=
"Hello, World!"
;
address
private
_owner
;
function
greet
()
external
view
returns
(
string
memory
)
{
return
_greeting
;
}
function
setGreeting
(
string
calldata
greeting
)
external
{
_greeting
=
greeting
;
}
function
owner
()
public
view
returns
(
address
)
{
return
_owner
;
}
}
La ejecución de nuestras pruebas muestra que volvemos a aprobar:
Compiling your contracts...
===========================
>
Compiling ./contracts/Greeter.sol>
Compiling ./contracts/Migrations.solContract: Greeter
✓ has been deployed successfully
greet()
✓ returns 'Hello, World!' (57ms)
owner()
✓ returns the address of the owner (50ms)
Contract: Greeter: update greeting
setGreeting(string)
✓ sets greeting to passed in string (125ms)
4 passing (292ms)
Lo que realmente queremos comprobar aquí es que la dirección del propietario es la misma que la dirección de implementación. Para ello, ahora añadiremos una prueba para comprobar que la dirección del propietario es igual a la cuenta predeterminada (la que se utiliza para desplegar el contrato). Para ello, necesitaremos acceder a las cuentas de nuestro entorno de pruebas, y por suerte Truffle ha hecho que esto sea accesible para su uso a través de la variable accounts
. Tendremos que pasarla al bloque de código de prueba de nivel contract
, como se ilustra en el Ejemplo 4-13.
Ejemplo 4-13. El propietario de la prueba es el mismo que el de la implementación
const
GreeterContract
=
artifacts
.
require
(
"Greeter"
);
contract
(
"Greeter"
,
(
accounts
)
=>
{
it
(
"has been deployed successfully"
,
async
()
=>
{
const
greeter
=
await
GreeterContract
.
deployed
();
assert
(
greeter
,
"contract failed to deploy"
);
});
describe
(
"greet()"
,
()
=>
{
it
(
"returns 'Hello, World!'"
,
async
()
=>
{
const
greeter
=
await
GreeterContract
.
deployed
();
const
expected
=
"Hello, World!"
;
const
actual
=
await
greeter
.
greet
();
assert
.
equal
(
actual
,
expected
,
"greeted with 'Hello, World!'"
);
})
});
describe
(
"owner()"
,
()
=>
{
it
(
"returns the address of the owner"
,
async
()
=>
{
const
greeter
=
await
GreeterContract
.
deployed
();
const
owner
=
await
greeter
.
owner
();
assert
(
owner
,
"the current owner"
);
});
it
(
"matches the address that originally deployed the contract"
,
async
()
=>
{
const
greeter
=
await
GreeterContract
.
deployed
();
const
owner
=
await
greeter
.
owner
();
const
expected
=
accounts
[
0
];
assert
.
equal
(
owner
,
expected
,
"matches address used to deploy contract"
);
});
});
})
Observa que nuestro bloque contract
pasa ahora un parámetro accounts
a la función que contiene nuestros casos de prueba. Nuestra nueva prueba afirma que la primera cuenta es la que ha implementado el contrato Greeter. Cuando ejecutemos las pruebas, obtendremos un fallo que nos informará de que owner
y expected
no coinciden. Ahora es el momento de escribir esa función constructora.
Hasta ahora hemos estado utilizando el constructor por defecto, constructor() public {}
. Ahora tenemos que registrar el msg.sender
como propietario cuando se inicialice el contrato. Nuestro contrato Greeter debería parecerse ahora al Ejemplo 4-14.
Ejemplo 4-14. Añadir un constructor al contrato Greeter
pragma solidity
>=
0
.
4
.
0
<
0
.
7
.
0
;
contract
Greeter
{
string
private
_greeting
=
"Hello, World!"
;
address
private
_owner
;
constructor
()
public
{
_owner
=
msg
.
sender
;
}
function
greet
()
external
view
returns
(
string
memory
)
{
return
_greeting
;
}
function
setGreeting
(
string
calldata
greeting
)
external
{
_greeting
=
greeting
;
}
function
owner
()
public
view
returns
(
address
)
{
return
_owner
;
}
}
Con este cambio, nuestras pruebas vuelven a pasar.
Ahora que sabemos quién creó el contrato, podemos crear una restricción para que sólo el propietario pueda actualizar el saludo. Este tipo de control de acceso se hace normalmente con un modificador de función.
Los modificadores de función nos permiten ampliar una función con código que puede ejecutarse antes y/o después de la función. Suelen adoptar la forma de una cláusula de guarda e impedirán que se invoque la función si no se cumple la cláusula, que es exactamente lo que queremos para nuestro contrato Greeter.
Enel Ejemplo 4-15 se han actualizado las pruebas de setGreeting
, estableciendo la expectativa de que sólo el propietario puede cambiar el saludo.
Ejemplo 4-15. Prueba de restricción de setGreeting al propietario
contract
(
"Greeter: update greeting"
,
(
accounts
)
=>
{
describe
(
"setGreeting(string)"
,
()
=>
{
describe
(
"when message is sent by the owner"
,
()
=>
{
it
(
"sets greeting to passed in string"
,
async
()
=>
{
const
greeter
=
await
GreeterContract
.
deployed
()
const
expected
=
"The owner changed the message"
;
await
greeter
.
setGreeting
(
expected
);
const
actual
=
await
greeter
.
greet
();
assert
.
equal
(
actual
,
expected
,
"greeting updated"
);
});
});
describe
(
"when message is sent by another account"
,
()
=>
{
it
(
"does not set the greeting"
,
async
()
=>
{
const
greeter
=
await
GreeterContract
.
deployed
()
const
expected
=
await
greeter
.
greet
();
try
{
await
greeter
.
setGreeting
(
"Not the owner"
,
{
from
:
accounts
[
1
]
});
}
catch
(
err
)
{
const
errorMessage
=
"Ownable: caller is not the owner"
assert
.
equal
(
err
.
reason
,
errorMessage
,
"greeting should not update"
);
return
;
}
assert
(
false
,
"greeting should not update"
);
});
});
})
En nuestras pruebas actualizadas, volvemos a pasar el parámetro accounts
a la función de devolución de llamada contract
para que estén disponibles para nuestros casos de prueba. También hemos añadido una segunda sección para el caso en que una cuenta no propietaria envíe el mensaje. Ahora esperamos que se produzca un error cuando una dirección no propietaria intente realizar este cambio. Si te fijas en la llamada a setGreeting
, ahora se pasa un segundo parámetro; es un objeto con una propiedad from
, y estamos enviando explícitamente este mensaje desde una cuenta diferente. Así es como también podríamos establecer un value
(en unidades de wei) para enviarlo al contrato.
Al ejecutar nuestras pruebas debería producirse un fallo como éste:
1 failing
1) Contract: Greeter: update greeting
setGreeting(string)
when message is sent by another account
does not set the greeting:
AssertionError: greeting should not update
Vamos a crear y aplicar nuestro modificador para arreglar esto. De vuelta en nuestro contrato Greeter, después de el constructor, añade el siguiente código:
modifier
onlyOwner
()
{
require
(
msg
.
sender
==
_owner
,
"Ownable: caller is not the owner"
);
_
;
}
La sintaxis de los modificadores se parece mucho a la sintaxis de las funciones, pero sin la declaración de visibilidad. Aquí, nuestro modificador utiliza la función require
, donde el primer argumento es una expresión que se evaluará como un booleano. Cuando esta expresión da como resultado un falso, la transacción se revierte por completo, es decir, se anulan todos los cambios de estado y el programa detiene la ejecución. La función revert
también toma un parámetro de cadena opcional que puede utilizarse para dar más información a quien llama sobre por qué falló la operación.
La última parte de nuestra función modificadora es la línea _;
. Esta línea es donde se llamará a la función que se está modificando. Si pones algo después de esta línea, se ejecutará después de que finalice el cuerpo de la función.
Ahora vamos a actualizar nuestra función setGreeter
para que utilice el modificador añadiendo la declaración onlyOwner
después de external
en la definición de la función:
function
setGreeting
(
string
calldata
greeting
)
external
onlyOwner
{
_greeting
=
greeting
;
}
Ejecutando nuestras pruebas, ¡vemos que todas pasan!
Esto está muy bien, pero vamos a hacer un último cambio antes de terminar este capítulo. En el Capítulo 2, hablamos de OpenZeppelin y de cómo han creado contratos que pueden utilizarse como base para crear fichas. Pues bien, también tienen contratos que implementan la idea de propiedad, y nosotros estamos duplicando parte de ese comportamiento. En lugar de duplicarlo, actualizaremos nuestro contrato Greeter para aprovechar su implementación.
De vuelta a nuestro terminal, en el directorio raíz de nuestra aplicación, introduce el siguiente comando:
$
npm install openzeppelin-solidity
Una vez completado esto, actualiza la parte superior del contrato Greeter para que tenga el aspecto del Ejemplo 4-16.
Ejemplo 4-16. Heredar de Ownable
pragma solidity
>=
0
.
4
.
0
<
0
.
7
.
0
;
import
"openzeppelin-solidity/contracts/ownership/Ownable.sol"
;
contract
Greeter
is
Ownable
{
...
rest
of
file
...
Aquí hemos añadido una sentencia import que extraerá todos los símbolos globales del archivo importado, como Ownable
, y los pondrá a disposición del ámbito actual. Lo siguiente que hay que observar es que nuestro contrato Greeter hereda ahora de Ownable
mediante la sintaxis is
. Solidity admite la herencia múltiple al igual que Python o incluso C++. Las clases que heredan se enumeran separadas por una coma.
Con esto en su sitio, podemos eliminar nuestras implementaciones del modificador onlyOwner
, la función getter owner
y la función constructora, ya que Ownable
proporciona estas definiciones. Esto puede parecer exagerado, y normalmente estaría de acuerdo con ese pensamiento. Sin embargo, utilizar código que haya sido sometido a una auditoría exhaustiva y bien probado es prudente cuando se trabaja con contratos inteligentes, ya que la seguridad de los contratos inteligentes es de vital importancia.
Con esto terminamos nuestro contrato Greeter. Pero antes de seguir adelante, reflexionemos sobre lo que hemos aprendido en este capítulo.
Resumen
Hasta ahora en nuestro viaje, hemos aprendido a crear un nuevo proyecto de contrato inteligente utilizando truffle init
, que proporcionó la estructura de directorios para nuestra aplicación. Esta estructura incluye directorios para alojar nuestros contratos, pruebas y migraciones.
Escribimos una prueba de implementación que nos ayudó a guiarnos a través de la migración inicial necesaria para introducir nuestro contrato en la red de pruebas. Esto permitió que nuestras pruebas posteriores interactuaran con el contrato desplegado.
Por último, empezamos a explorar el lenguaje Solidity y conocimos los distintos modificadores de visibilidad de las funciones (external
, public
, internal
, private
). Esos mismos modificadores también están disponibles para las variables de estado (con la excepción de external
) utilizadas en la persistencia de datos en la blockchain. También implementamos el concepto de Ownable
y lo refactorizamos para utilizar el contrato OpenZeppelin
mediante herencia.
En el próximo capítulo, vamos a ver cómo implementar nuestro contrato localmente y también en una de las redes de prueba disponibles públicamente.
Get Desarrollo práctico de contratos inteligentes con Solidity y Ethereum now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.