Todas as vezes que comecei a aprender uma nova linguagem de programação, logo depois de escrever o primeiro Olá Mundo, eu inicio um projeto de CRUD. CRUD é uma abreviação para Create, Retrieve, Update e Delete, ou seja, as 4 operações elementares em um sistema de cadastro. E com Solidity não foi diferente, ajudando a descobrir diversas coisas fundamentais desta tecnologia. Neste tutorial de hoje eu vou lhe ensinar como criar um smart contract de CRUD de clientes com Solidity, HardHat e testes escritos em JavaScript.
Este não deve ser o seu primeiro contato com Solidity ou com HardHat, para isso recomendo que tenha feito este tutorial antes.
Vamos lá!
#1 – Criando o Projeto
Para criar esse projeto você vai precisar ter Node.js instalado na sua máquina. Crie uma pasta crud-solidity-hardhat onde for mais prático para você, abra o terminal e navegue até ela, executando os comandos para criação do projeto HardHat de exemplo (selecione a opção TypeScript e o resto deixe default).
1 2 3 4 5 |
npm init -y npm i -D hardhat npx hardhat |
Agora limpe as pastas contracts, scripts e test.
Na pasta contracts, crie um novo arquivo chamado BookDatabase.sol, que será o nosso contrato de CRUD de livros. Dentro dele, defina a licença, a versão e deixe a estrutura inicial do contrato pronta, como abaixo.
1 2 3 4 5 6 7 8 |
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract BookDatabase { } |
Repare que estou usando a versão 0.8.20 ou superior, você deve usar a versão que possuir instalada na sua máquina, a partir da que informei acima. E por fim, vamos deixar a estrutura de nossos testes preparada, criando na pasta test um arquivo BookDatabase.test.ts e deixando a estrutura abaixo nele.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; import { expect } from "chai"; import { ethers } from "hardhat"; describe("BookDatabase", () => { async function deployFixture() { const [owner, otherAccount] = await ethers.getSigners(); const BookDatabase = await ethers.getContractFactory("BookDatabase"); const bookDatabase = await BookDatabase.deploy(); return { bookDatabase, owner, otherAccount }; } }); |
Aqui estamos carregando as importações necessárias, definindo uma suíte de testes (describe) e criando a função de deploy fixture, que serve para o contrato ser provisionado apenas uma vez e limpo a cada teste, além de termos acesso às carteiras de teste.
Com isso nosso projeto está pronto para implementarmos o CRUD.
#2 – Cadastrando Livros
A primeira operação do CRUD que vamos fazer é o cadastro. Como em Solidity tudo é persistido no disco por padrão, basta que a gente crie alguma estrutura de dados e guarde um objeto dentro dela e teremos o mesmo salvo na blockchain. Para que não vire uma bagunça e tenhamos uma estrutura para estes dados, eu proponho que a gente comece criando uma struct com os parâmetros que todo cliente deve ter.
Uma struct, caso não conheça, é uma estrutura de dados que define os campos que um objeto deve ter, como se fosse uma classe anêmica, ou seja, sem funções. Em Solidity, podemos definir structs assim (dentro do contrato).
1 2 3 4 5 6 |
struct Book { string title; uint16 year; } |
Isso quer dizer que toda vez que quisermos salvar um livros, precisamos definir o título e ano de lançamento dele dele (adicione outras propriedades conforme seu interesse). Repare aqui que usei uint16 para o ano, pois não teremos anos negativos e em 16-bit a gente consegue representar até o número 65535, mais do que suficiente para anos, lembrando que na blockchain nós vamos pagar mais caro por dados maiores, então é bom economizar nas variáveis.
Agora que temos nossa struct, podemos criar uma estrutura que armazene vários livros em nosso contrato. Optei por usar um mapping, que é como se fosse um array associativo ou dicionário de objetos, mas o termo mais correto seria que um mapping é um hashmap, ou seja: dado um índice definido por você (um id único) ele aponta (map = mapeia) para onde está armazenada uma informação. Esse tipo de estrutura é muito eficiente para busca e inserção.
Para definir nosso mapping eu vou determinar que cada cliente vai ser referenciado pelo seu id único positivo de 32-bit (uint32).
1 2 3 |
mapping(uint32 => Book) public books; |
Repare como disse que o id aponta para objeto Book, que é o tipo da struct que definimos antes. Além disso coloquei que nosso mapping books é público, ou seja, pode ser lido por qualquer um na blockchain.
Para podermos adicionar novos clientes neste mapping de livros precisamos ter alguma forma de gerar ids únicos, a fim de que cada livro tenha seu próprio id. Para isso, vou criar uma propriedade de controle e uma função que sempre me retorna o próximo id único a ser utilizado, como abaixo. Lembrando que Solidity guarda os dados na blockchain e nela tudo salva no disco por padrão, então estes ids estão sempre salvos corretamente.
1 2 3 4 5 6 7 |
uint32 private nextId = 0; function getNextId() private returns (uint32) { return ++nextId; } |
Comecei definindo nosso id como zero e deixando-o como privado. Depois disso defini uma função que incrementa e retorna o id a ser usado no cadastro de um novo cliente. Repare que também deixei a função como privada pois é para uso interno do nosso contrato apenas.
Agora podemos criar a função pública que adiciona um novo livros com um novo id único, como abaixo.
1 2 3 4 5 6 |
function addBook(Book memory book) public { uint32 id = getNextId(); books[id] = book; } |
Como o nosso struct Book não é um tipo primitivo, devemos usar o modificador memory na declaração do parâmetro dele. Isso quer dizer que por parâmetro devemos receber um objeto Book, gerar um id para ele e salvá-lo em nosso mapping com o id sendo a chave e o book sendo o valor.
Para testar esta nossa função Solidity nós devemos criar um teste TS em nosso arquivo de testes. No entanto, as funções que salvam dados na blockchain sempre retornam recibos de transação e não algum valor que possa ser facilmente verificado em uma asserção (assert). Para mudarmos isso, vamos adicionar uma variável de contagem em nosso contrato, para sabermos quantos livros já cadastramos nele, como abaixo.
1 2 3 4 5 6 7 8 9 |
uint32 public count; function addBook(Book memory book) public { uint32 id = getNextId(); books[id] = book; count++; } |
Além de declarar a variável de contagen, adicionei mais uma linha na função de cadastro para incrementar o count a cada adição. Com isso podemos escrever o nosso teste que cadastra um novo livro e verifica se a contagem aumentou. Para quem já escreveu testes em Jest ou Tape antes, não tem nenhuma novidade aqui.
1 2 3 4 5 6 7 8 9 10 11 12 |
it("Should add book", async () => { const { bookDatabase } = await loadFixture(deployFixture); await bookDatabase.addBook({ title: "Criando apps para empresas com Android", year: 2015 }); expect(await bookDatabase.count()).to.equal(1); }); |
Para rodar nosso teste, execute o comando ‘npx hardhat test’ no terminal e tudo deve passar como esperado.
#3 – Retornando um Livro
Agora vamos fazer o R do CRUD, criando uma nova função em nosso smart contract para retornar um livro já salvo.
1 2 3 4 5 |
function getBook(uint32 id) public view returns (Book memory) { return books[id]; } |
Essa função Solidity é bem simples pois ela espera o id por parâmetro e usa ele como chave no mapping para retornar o Book. Apenas atenção ao fato dela ser view (somente leitura) e que retorna um Book memory, já que não preciso salvar o retorno em disco, ele deve ser em memória.
Para escrever o teste desta função em nosso arquivo de testes é bem simples também, como abaixo.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
it('Get Book', async () => { const { bookDatabase } = await loadFixture(deployFixture); await bookDatabase.addBook({ title: "Criando apps para empresas com Android", year: 2015 }); const book = await bookDatabase.getBook(1); expect(book.title).to.equal("Criando apps para empresas com Android"); }) |
Como o deployFixture garante que meu contrato seja inicializado como novo a cada teste, eu terei de fazer um cadastro inicial e depois posso chamar o getBook para buscar o livro de id 1 e ver se retornou o livro de título certo.
Em muitos testes de smart contracts você vai ter de ou deixar uma carga de dados pronta antes de todos testes (beforeAll) ou preparar o teste logo no início dos mesmos com os dados necessários, um a um.
Rode os testes com npx hardhat test e deve estar tudo passando.
#4 – Atualizando um Livro
Agora vamos para o U de Update em nosso CRUD. Aqui temos uma complicação que a função de edit vai sempre receber um objeto Book mas nem sempre iremos querer alterar todos os dados de um livro. Sendo assim, devemos criar uma lógica para comparar campo a campo e alterar somente aqueles que mudaram de fato.
Até aí tudo bem, exceto pelo fato de que o campo title é uma string. String são um problema em linguagens como C e Solidity pois elas não são tipos primitivos e são armazenadas de um jeito que dificulta a sua comparação. Abaixo proponho uma função simples para comparação de strings que atende bem para casos simples.
1 2 3 4 5 6 7 8 9 |
function compareStrings(string memory a, string memory b) private pure returns (bool) { return keccak256(bytes(a)) == keccak256(bytes(b)); } |
Dada duas strings recebidas em memóry (a e b), nós pegamos os bytes deles e geramos um hash com eles, usando a função keccak256 (este é um algoritmo de hashing nativo do Solidity). Se os hashes de ambas forem iguais, quer dizer que eram a mesma palavra. Achou complicado? Outra opção é você fazer um for e comparar byte a byte da string, já que a comparação direta com sinais de igualdade não funciona com a string inteira.
Agora que temos a nossa função de comparação de string retornando um booleano quando duas strings forem iguais, podemos criar a nossa função de edição que compara campo a campo para alterar somente aqueles que foram informados e que mudaram em relação aos valores já armazenados.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function editBook(uint32 id, Book memory newBook) public { Book memory oldBook = books[id]; if (bytes(oldBook.title).length == 0) return; if ( bytes(newBook.title).length > 0 && !compareStrings(oldBook.title, newBook.title) ) oldBook.title = newBook.title; if (newBook.year > 0 && oldBook.year != newBook.year) oldBook.year = newBook.year; books[id] = oldBook; } |
Repare que eu começo testando se o título do livro antigo tem comprimento (length). Isso porque caso o id passado não seja de nenhum livro, o Solidity vai me retornar um livro com campos vazios ao invés de um erro ou null como seria comum em outras linguagens. Neste caso, eu não posso editar um livro que não existe, certo?
Depois disso, a gente testa, campo a campo, se o campo foi informado na edição e se ele é diferente do valor antigo naquele campo do cliente. Se os valores divergirem fazemos a troca e no final de tudo, a adição por cima do registro antigo usando o mesmo id no mapping.
Agora, para escrevermos o teste desta função nós precisamos fazer o cadastro antes da edição e depois a consulta para ver se somente o campo que passamos, mudou.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
it('Edit Book', async () => { const { bookDatabase } = await loadFixture(deployFixture); await bookDatabase.addBook({ title: "Criando apps para empresas com Android", year: 2015 }); await bookDatabase.editBook(1, { title: "Criando apps com Android", year: 0 }); const book = await bookDatabase.getBook(1); expect(book.title).to.equal("Criando apps com Android"); expect(book.year).to.equal(2015); }) |
Repare como informei ano 0 na edição, para que ela seja ignorada e se mantenha o ano original do cadastro. Assim no teste consigo testar os dois campos, para ver se ficaram como espero, apenas com o título alterado.
Se rodar a bateria de testes com npx hardhat test, todos devem passar.
#5 – Excluindo um Livro
Agora vamos para a quarta e última operação do CRUD: o D de Delete. Aqui vamos inventar um complicador para aprendermos um pouco mais de Solidity. Vou definir uma regra que somente o criador do contrato pode excluir livros. Não me pergunte se faz sentido, é apenas uma brincadeira para aprendermos algo novo.
Como criador do contrato você tem duas opções: um endereço de carteira arbitrário ou então pegar o endereço da carteira que fez o deploy do contrato na blockchain. Eu vou usar a segunda opção por ela envolver mais conceitos novos, mas é fácil de você mudar para a primeira se assim desejar.
Quando você faz o deploy do contrato o seu construtor é chamado. Nesta invocação, podemos pegar o endereço da carteira de quem fez o send (envio de transação) e armazená-lo de forma imutável em nosso contrato como sendo o dono (owner) dele. Abaixo um exemplo de como fazer isso.
1 2 3 4 5 6 7 |
address private immutable owner; constructor() { owner = msg.sender; } |
O tipo address é um tipo primitivo do Solidity para armazenamento de endereços (carteiras, contratos, etc), como a variável owner é um endereço de carteira, nos atende muito bem. Além disso deixarei ela como privada e como immutable, o que faz com que ela seja uma constante que eu posso definir uma única vez, via constructor do contrato.
No constructor eu faço essa definição, pegando o msg.sender que nada mais é do que o endereço da carteira que está enviando a transação atual (msg, que é um objeto global). Como o constructor é chamado somente no deploy do contrato, a transação atual será a de deploy, feita pelo dono do mesmo. Ao invés disso, caso deseje deixar um owner fixo no código, troque immutable para constant e defina o valor de owner com o endereço no formato hexadecimal (0x…).
Tudo isso para que, na nossa função de remoção de livros a gente possa exigir que somente o dono possa enviar transações de exclusão. Essa exigência é feita através da chamada require logo no início da função, que determina a condição para que a transação possa avançar, comparando o sender da msg com o owner já armazenado no contrato.
1 2 3 4 5 6 7 8 9 10 |
function removeBook(uint32 id) public { require(msg.sender == owner, "Caller is not owner"); Book memory oldBook = books[id]; if (bytes(oldBook.title).length > 0) { delete books[id]; count--; } } |
Se a exigência for atendida, a execução avança pegando o livro já salvo no mapping, para ver se ele existe, excluindo ele se existir e decrementando o contador de livros para que a contagem se mantenha fiel.
Repare que eu somente faço o teste de existência do livro para evitar decrementar o count sem necessidade, a fim de que ele realmente esteja preciso na sua contagem.
Para escrever o teste desta função é bem simples, devemos apenas cadastrar um livro primeiro, depois removê-lo e por fim ver a contagem.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
it('Remove Book', async () => { const { bookDatabase, owner } = await loadFixture(deployFixture); await bookDatabase.addBook({ title: "Criando apps para empresas com Android", year: 2015 }); await bookDatabase.removeBook(1, { from: owner.address }); expect(await bookDatabase.count()).to.equal(0); }) |
Aqui eu incluí um objeto de configurações da transação na chamada ao removeBook. Quando você passar owner.address para o from, você está dizendo que a transação deve ser invocada pela conta de teste que fez o deploy do contrato e o teste deve passar normalmente. Lembrando que essa conta é retornada na função de deployFixture.
No entanto, experimente usar a outra conta retornada pelo deployFixture (otherAccount) para você ver que deve dar erro no teste, pois assim que estará chamando a função, e consequentemente tentando fazer a transação, não será a mesma conta que fez o deploy e portanto não atende à exigência do require no contrato.
Com isso, ao rodar o comando npx hardhat test, todos os seus testes devem estar passando, como abaixo.
Quer aprender mais técnicas de testes de smart contracts? Leia este tutorial.
Quer aprender como fazer deploy deste Smart Contract na Blockchain? Leia este tutorial.
Agora se estiver buscando aprender a criar algo mais complexo, recomendo este tutorial de novo token ERC-20.
E se quiser aprender a reduzir o consumo de gás nos seus smart contracts, este é o artigo certo.
Até mais!
Olá, tudo bem?
O que você achou deste conteúdo? Conte nos comentários.