Confira o processo de desenvolvimento de contratos inteligentes em Solidity. Testes unitários de acordo com o TDD (Test Driven Development ou em português, Desenvolvimento Guiado por Testes).
Isenção de responsabilidade:
A solução apresentada aqui é uma apresentação educacional e não está pronta para implantação. Estou aprendendo sozinho para que erros que levem a vulnerabilidades graves possam estar presentes nas soluções mostradas. Tenha cuidado ao se referir a este artigo, pois vários riscos podem estar envolvidos, incluindo a perda de seu dinheiro e de seus usuários.
Lembre-se de que a implantação de contratos inteligentes inevitavelmente traz várias vulnerabilidades e riscos e pode ser explorada contra você e sua solução.
Abaixo, mostro todo o processo de como codifico o contrato inteligente e como penso sobre o problema.
Nota: Para navegar por um resumo rápido dos contratos inteligentes que pagam dividendos, verifique meu outro post.
Definição do problema
- O apartamento é muito caro para um único indivíduo comprar. Um grupo de amigos ou investidores pode comprar um apartamento juntos e depois dividir a renda desse investimento proporcionalmente ao que cada um pagou.
- Qualquer pessoa hospedada no apartamento paga o aluguel diretamente no endereço do contrato inteligente do apartamento.
- Essa renda pode ser retirada por qualquer acionista a qualquer momento, proporcionalmente ao número de ações.
Ferramentas
Eu usarei o VS Code
com o hardhat instalado. Estarei escrevendo testes de unidade em chai.js
entregues com o hardhat pronto para uso. Presumo que usarei a implementação erc20 do openzepellin
e tratarei cada uma das 100 ações como uma única moeda.
Repositório
O repositório do Github apresenta um caminho para alcançar os resultados finais de forma que cada etapa (teste de unidade) seja enviada separadamente com a descrição do caso de teste como a mensagem do commit.
Conteúdo (Títulos dos testes de unidade):
- O criador do contrato deve ter 100 ações do apartamento.
- Deve ser possível transferir algumas ações para outro usuário.
- Deve ser possível pagar o aluguel e depositá-lo em ETH no contrato do apartamento
- O proprietário deve poder sacar recursos pagos como aluguel
- O acionista deve poder sacar recursos pagos a título de aluguel
- A tentativa de retirada por não acionista deve ser revertida
- O acionista do apartamento poderá sacar recursos proporcionais à sua parte
- Não deve ser possível retirar mais do que um possui
- Deve ser possível sacar várias vezes, desde que haja rendimentos entre os saques
- Cada retirada deve ser calculada com base na nova receita, não no saldo total
- A transferência de ações deve retirar os fundos atuais de ambas as partes
Caso de teste 1: O criador do contrato deve ter 100 ações do apartamento.
Vamos começar com o caso de teste. Presumimos que o apartamento terá apenas 100 ações e que todas elas estarão inicialmente na posse do proprietário do contrato que também é o proprietário inicial do imóvel.
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
import "hardhat/console.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract Apartment is ERC20 {
constructor() ERC20("ApartmentContract", "APRTM") {
super._mint(_msgSender(), 100);
console.log("Implantando um Greeter com saudacao:");
}
}
import { expect } from "chai";
import { BigNumber } from "ethers";
import { ethers } from "hardhat";
let owner, Alice, Bob, Joe;
describe("Apartment", function () {
it("O criador do contrato deve ter 100 ações do apartamento",async () => {
const Apartment = await ethers.getContractFactory("Apartment");
const apartment = await Apartment.deploy();
[owner, Alice, Bob, Joe] = await ethers.getSigners();
await apartment.deployed();
let ownerBalance = await apartment.balanceOf(owner.address);
expect(ownerBalance).to.equal(100);
})
});
No ERC20
, temos a função _mint
, que é muito útil nesse caso, pois não requer esforço para cumprir o primeiro teste.
Caso de teste 2: Deve ser possível transferir algumas ações para outro usuário
Este caso de teste afirma que as ações do apartamento podem simplesmente ser transferidas para outra pessoa. Por exemplo, para o segundo investidor.
Como descrito anteriormente no contexto do problema, uma única pessoa não pode pagar o apartamento para si mesma, então convida outros (acionistas também conhecidos como co-investidores) para participar tanto dos custos quanto das receitas.
Não há necessidade de implementação no lado do contrato inteligente porque, novamente, está tudo no padrão ERC20
.
it("Deve ser possível transferir algumas ações para outro usuário", async () => {
const Apartment = await ethers.getContractFactory("Apartment");
const apartment = await Apartment.deploy();
[owner, Alice] = await ethers.getSigners();
await apartment.deployed();
await apartment.transfer(Alice.address, 20);
expect(await apartment.balanceOf(Alice.address)).to.equal(20);
expect(await apartment.balanceOf(owner.address)).to.equal(80);
})
Caso de teste 3: Deve ser possível pagar o aluguel e depositá-lo em ETH no contrato do apartamento
O objetivo deste problema é permitir que os investidores ganhem dinheiro. Eles querem ganhar dinheiro adquirindo o apartamento e alugando-o. A beleza do contrato inteligente é que ele tem toda a lógica implementada dentro dele. Se o contrato tiver algum dinheiro, ele o administrará de acordo com a lógica programada. Não haverá necessidade de ir ao banco para sacar dinheiro e dividi-lo entre qualquer um dos investidores. O contrato inteligente executará isso fazendo com que todo o fluxo de caixa seja:
- conveniente - nenhuma ação necessária, automação introduzida
- de necessidade mínima de confiança - ninguém tem todo o dinheiro nem por um momento
Este caso de teste garante que haja um método no contrato inteligente que será chamado sempre que uma transferência de fundos for chamada. Este método será recebido (receive
) e decorado com um modificador externo (external
). Mais sobre a lógica por trás dessa função na palavra-chave receive.
Então, vamos ver o código e o caso de teste de unidade:
contract Apartment is ERC20 {
uint public balance;
constructor() ERC20("ApartmentContract", "APRTM") {
super._mint(_msgSender(), 100);
console.log("Implantando um Greeter com saudacao:");
}
receive() external payable {
console.log("receive");
balance += msg.value;
}
}
it("Deve ser possível pagar o aluguel e depositá-lo em ETH no contrato do apartamento", async () => {
const Apartment = await ethers.getContractFactory("Apartment");
const apartment = await Apartment.deploy();
[owner, Alice, Bob] = await ethers.getSigners();
await apartment.deployed();
await Bob.sendTransaction({
to: apartment.address,
value: ethers.utils.parseEther("1")
})
expect(await apartment.balance()).to.equal(ethers.utils.parseEther("1"));
})
Novo método 'receive' e teste de unidade, certificando-se de que tudo funciona corretamente
Como vemos no caso de teste, há outro participante na jogada, representado por Bob
. Ele não é um investidor. Ele é o hóspede do apartamento e paga a estadia diretamente no endereço do contrato inteligente. Como acontece, o saldo do contrato inteligente é aumentado e pode ser consultado. Nas próximas etapas, esses recursos pagos serão uma questão de distribuição proporcional entre os investidores. Fique ligado!
Caso de teste 4: O proprietário deve poder sacar recursos pagos como aluguel
Esta lição é o início de vários commits sobre a retirada de fundos da funcionalidade do contrato inteligente. De um modo geral, o objetivo é permitir que os acionistas (e apenas os acionistas) retirem uma quantidade aplicável de fundos do contrato inteligente. Supõe-se que seja seguro de tal forma que os acionistas não possam chamar essa função de drenar infinitamente os fundos do contrato, além de permitir sempre sacar a quantidade certa de fundos. Calcular a quantia certa de fundos parece fácil, mas exigirá a análise de vários casos e será discutido em alguns casos de teste para simplificar. Um detalhe de cada vez.
Nesta lição, há apenas o ponto de partida adicionado. Não há nenhum mecanismo de segurança e deixar como está trará consequências graves, como perder todos os fundos, pois qualquer pessoa pode chamar para retirar todos os fundos sem matemática envolvida.
Vamos dar uma olhada no método de retirada que, por enquanto, é apenas um ponto de partida.
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
import "hardhat/console.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract Apartment is ERC20 {
uint public balance;
constructor() ERC20("ApartmentContract", "APRTM") {
super._mint(_msgSender(), 100);
console.log("Implantando um Greeter com saudacao:");
}
function withdraw() public {
payable(msg.sender).transfer(address(this).balance);
}
receive() external payable {
console.log("receive");
balance += msg.value;
}
}
it("Proprietário deve poder sacar recursos pagos como aluguel", async () => {
const Apartment = await ethers.getContractFactory("Apartment");
const apartment = await Apartment.deploy();
[owner, Alice, Bob] = await ethers.getSigners();
await apartment.deployed();
await apartment.transfer(Alice.address, 20);
await Bob.sendTransaction({
to: apartment.address,
value: ethers.utils.parseEther("1")
});
const ownerBalanceBeforeWithdrawal = await owner.getBalance();
await apartment.withdraw();
expect(await (await owner.getBalance()).gt(ownerBalanceBeforeWithdrawal)).to.be.true;
Teste de unidade para o método de contrato inteligente, certificando-se de que o saldo do chamado foi alterado
O teste de unidade correspondente garante que o saldo do chamador após a transação seja maior do que antes.
Caso de teste 5: O acionista deve poder sacar recursos pagos a título de aluguel
Não temos grandes coisas neste incremento. Adicionamos algumas proteções contra o uso indevido da função de retirada. Na lição anterior, literalmente, todos podiam chamar esse método e retirar todos os fundos. Bem assustador, hein? No momento, limitamos as possibilidades de chamá-lo apenas para aqueles que têm pelo menos uma ação (mais de zero para ser mais preciso). Vamos lembrar que o código ainda não oferece segurança, pois agora qualquer acionista pode drenar um contrato inteligente a qualquer momento. É apenas um pouco melhor do que permitir fazer o mesmo com literalmente qualquer pessoa. Mas não muito :)
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
import "hardhat/console.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract Apartment is ERC20 {
uint public balance;
constructor() ERC20("ApartmentContract", "APRTM") {
super._mint(_msgSender(), 100);
console.log("Implantando um Greeter com saudacao:");
}
function withdraw() public {
require(this.balanceOf(msg.sender) > 0, "XXX");
payable(msg.sender).transfer(address(this).balance);
}
receive() external payable {
console.log("receive");
balance += msg.value;
}
}
it("Acionista poderá sacar recursos pagos a título de aluguel", async () => {
const Apartment = await ethers.getContractFactory("Apartment");
const apartment = await Apartment.deploy();
[owner, Alice, Bob] = await ethers.getSigners();
await apartment.deployed();
await apartment.transfer(Alice.address, 20);
await Bob.sendTransaction({
to: apartment.address,
value: ethers.utils.parseEther("1")
});
const aliceBalanceBeforeWithdrawal = await Alice.getBalance();
await apartment.connect(Alice).withdraw();
expect(await (await Alice.getBalance()).gt(aliceBalanceBeforeWithdrawal)).to.be.true;
})
Aqui testamos que não o proprietário, mas o acionista pode chamar “retirar” e seu saldo é aumentado
Caso de teste 6: A tentativa de retirada por não acionista deve ser revertida
Pouco a discutir aqui. A única novidade é uma mensagem significativa quando uma pessoa não autorizada (unathorized
) chama esse método. O mais importante aqui é o teste de unidade que cuida de garantir essa condição. Apenas os acionistas podem fazer retiradas, e qualquer tentativa de retirada quando você não for um deve ser rejeitada. Legal que o Hardhat permite esse teste de unidade, que testa explicitamente as rejeições de chamadas específicas. Eu já escrevi sobre testar rejeições de transações aqui.
it("A tentativa de retirada por não acionista deve ser revertida", async () => {
const Apartment = await ethers.getContractFactory("Apartment");
const apartment = await Apartment.deploy();
[owner, Alice, Bob] = await ethers.getSigners();
await apartment.deployed();
await apartment.transfer(Alice.address, 20);
await Bob.sendTransaction({
to: apartment.address,
value: ethers.utils.parseEther("1")
});
await expect(apartment.connect(Bob).withdraw()).to.be.revertedWith("unauthorized");
})
Caso de teste 7: O acionista do apartamento poderá sacar recursos proporcionais à sua cota
Tudo bem, agora aqui vai um pouco de matemática. Não podemos permitir que ninguém retire todos os fundos, mas apenas fundos proporcionalmente às próprias ações do apartamento. Vamos dar uma olhada na função.
Como vemos, a matemática é simples, mas nos permite calcular os fundos exatos a serem sacados. A matemática aqui é simples, pois se baseia em matemática de números inteiros. Seria muito mais complicado se esses números exigissem arredondamento. Como sempre, há um teste de unidade designado, garantindo que essa coisa funcione conforme o esperado. Agora e no futuro.
function withdraw() public {
require(this.balanceOf(msg.sender) > 0, "unauthorized");
uint meansToWithdraw = address(this).balance / 100 * this.balanceOf(msg.sender);
balance = balance - meansToWithdraw;
payable(msg.sender).transfer(meansToWithdraw);
}
receive() external payable {
balance += msg.value;
}
it("O acionista do apartamento poderá sacar recursos proporcionais à sua parte", async () => {
const Apartment = await ethers.getContractFactory("Apartment");
const apartment = await Apartment.deploy();
[owner, Alice, Bob] = await ethers.getSigners();
await apartment.deployed();
await apartment.transfer(Alice.address, 20);
await Bob.sendTransaction({
to: apartment.address,
value: ethers.utils.parseEther("1")
});
const aliceBalanceBeforeWithdrawal = await Alice.getBalance();
await apartment.connect(Alice).withdraw();
expect(await (await apartment.balance()).eq(ethers.utils.parseEther("0.8"))).to.be.true;
expect(await (await apartment.balance()).gt(ethers.utils.parseEther("0"))).to.be.true;
expect(await (await Alice.getBalance()).gt(aliceBalanceBeforeWithdrawal)).to.be.true;
})
O teste de unidade apenas garante que haja o aumento correto (estimado) no saldo do usuário e também a quantidade certa de fundos restantes no contrato após a transação. A parte que falta aqui é que, embora calculemos com precisão o valor dos fundos, não limitamos o número de chamadas consecutivas para essa função. Vamos ver esse problema na próxima lição.
Caso de teste 8: Não deve ser possível retirar mais de um possui
Mesmo que Alice (do teste de unidade acima) não possa sacar mais do que o valor proporcional às suas ações, ela pode facilmente trapacear e chamar a função várias vezes, quase drenando o contrato inteligente para zero. Um exemplo do que pode acontecer abaixo:
- Alice retira 20% de 1 Ether (Alice tem 0,2 Ether, Contrato tem 0,8)
- Alice retira 20% de 0,8 Ether (Alice: 0,2 + 0,16, Contrato 0,64)
- Alice retira 20% de 0,64 Ether (Alice: 0,488, Contrato 0,512)
Com cada retirada consecutiva, Alice receberá menos fundos, mas de qualquer forma ela poderá obter quase todos os fundos rapidamente
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
import "hardhat/console.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract Apartment is ERC20 {
uint public balance;
uint public totalIncome;
mapping(address => uint) withdrawRegister;
constructor() ERC20("ApartmentContract", "APRTM") {
super._mint(_msgSender(), 100);
console.log("Implantando um Greeter com saudacao:");
}
function withdraw() public {
require(this.balanceOf(msg.sender) > 0, "unauthorized");
require(totalIncome > withdrawRegister[msg.sender], "0 fundos para saque");
uint meansToWithdraw = address(this).balance / 100 * this.balanceOf(msg.sender);
balance = balance - meansToWithdraw;
withdrawRegister[msg.sender] = totalIncome;
payable(msg.sender).transfer(meansToWithdraw);
}
receive() external payable {
balance += msg.value;
totalIncome +=msg.value;
}
}
- Há um novo mapeamento que foi introduzido. Nesse mapeamento de cada acionista, guardo a receita total recebida até agora no contrato inteligente. É algo como um ponteiro para qual era o estado do contrato inteligente da última vez que alguém fez uma retirada. Isso pode ficar como na imagem abaixo.
- Ao fazer o registro sempre que alguém tentar fazer um saque, apenas certifico-me de que há algo novo recebido desde o último saque desse usuário.
- Desde que haja novos fundos ganhos, o usuário pode seguir em frente e retirar sua parte desses novos fundos*. No entanto, temos a renda total do contrato atual anotada ao lado de seu nome e da próxima vez que houver uma retirada, esse valor será usado para verificação.
sua parte desses novos fundos* — Na verdade, isso não é verdadeiro. O usuário receberá 20% de todos os fundos no contrato inteligente e não apenas 20% nos novos fundos. Isso será levado em consideração na lição 10
.
Representação simplificada do registro de saque
Vamos agora dar uma olhada no teste de unidade. Como afirmado aqui, não há como chamar o saque duas vezes, então para cada novo rendimento, Alice só poderá chamá-lo uma vez.
it("Não deve ser possível retirar mais do que um deveria", async () => {
const Apartment = await ethers.getContractFactory("Apartment");
const apartment = await Apartment.deploy();
[owner, Alice, Bob] = await ethers.getSigners();
await apartment.deployed();
await apartment.transfer(Alice.address, 20);
await Bob.sendTransaction({
to: apartment.address,
value: ethers.utils.parseEther("1")
});
await apartment.connect(Alice).withdraw();
await expect(apartment.connect(Alice).withdraw()).to.be.revertedWith("0 fundos para saque");
})
Caso de teste 9: Deve ser possível sacar várias vezes, desde que haja rendimentos entre os saques
Não há um novo código do Solidity aqui, apenas garantindo que ainda seja possível sacar várias vezes se os novos fundos estiverem aparecendo contrato inteligente nesse meio tempo.
it("Deve ser possível sacar várias vezes, desde que haja rendimentos entre eles", async () => {
const Apartment = await ethers.getContractFactory("Apartment");
const apartment = await Apartment.deploy();
[owner, Alice, Bob] = await ethers.getSigners();
await apartment.deployed();
await apartment.transfer(Alice.address, 20);
await Bob.sendTransaction({
to: apartment.address,
value: ethers.utils.parseEther("1")
});
await apartment.connect(Alice).withdraw();
await Bob.sendTransaction({
to: apartment.address,
value: ethers.utils.parseEther("1")
});
await expect(apartment.connect(Alice).withdraw()).not.to.be.revertedWith("0 fundos para saque");
})
Caso de teste 10: Cada saque deve ser calculado em relação à nova receita, não ao saldo total
Conforme mencionado na lição 8, cada retirada deve ser calculada com base nos novos fundos recebidos no contrato inteligente, não no total de fundos disponíveis, como tem sido o caso até agora.
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
import "hardhat/console.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract Apartment is ERC20 {
uint public balance;
uint public totalIncome;
mapping(address => uint) withdrawRegister;
constructor() ERC20("ApartmentContract", "APRTM") {
super._mint(_msgSender(), 100);
console.log("Implantando um Greeter com saudacao:");
}
function withdraw() public {
require(this.balanceOf(msg.sender) > 0, "unauthorized");
require(totalIncome > withdrawRegister[msg.sender], "0 fundos para saque");
uint meansToWithdraw = (totalIncome - withdrawRegister[msg.sender]) / 100 * this.balanceOf(msg.sender);
balance = balance - meansToWithdraw;
withdrawRegister[msg.sender] = totalIncome;
payable(msg.sender).transfer(meansToWithdraw);
}
receive() external payable {
balance += msg.value;
totalIncome +=msg.value;
}
}
Como vemos, há um grande uso do método withdrawRegister
. Graças a isso, não apenas limitamos as abordagens de retirada injustas, mas também calculamos o valor da retirada com base na nova receita da última retirada daquele usuário. Isso garante que sempre teremos fundos para os acionistas mais perseverantes, que são mais pacientes e não fazem saques com a mesma frequência que os outros.
É assim que a nova renda é calculada com base no registro e na renda total do apartamento
Desta vez, há um teste de unidade massivo criando todo o histórico de várias operações, como várias receitas e saques.
it("Cada retirada deve ser calculada em relação à nova receita, não ao saldo total", async () => {
const Apartment = await ethers.getContractFactory("Apartment");
const apartment = await Apartment.deploy();
[owner, Alice, Bob] = await ethers.getSigners();
await apartment.deployed();
await apartment.transfer(Alice.address, 20);
//Saldo da Alice salvo para comparação posterior
const aliceInitialBalanceBeforeWithdrawals = await Alice.getBalance();
//1.
//Bob transfere seu aluguel
await Bob.sendTransaction({
to: apartment.address,
value: ethers.utils.parseEther("1")
});
//Alice faz uma retirada (20/100 * 1E) =~0,2E
await apartment.connect(Alice).withdraw();
//Conta da Alice salva após retirada para comparação
const aliceBalanceAfterFirstWithdrawal = await Alice.getBalance();
//2.
//Bob transfere seu aluguel
await Bob.sendTransaction({
to: apartment.address,
value: ethers.utils.parseEther("1")
});
//Alice faz saque (20/10 * newIncome(1E)) =~0.2E
await apartment.connect(Alice).withdraw();
//Conta da Alice após todos os saques salvos para comparação
const aliceBalanceAfterSecondWithdrawal = await Alice.getBalance();
expect(aliceInitialBalanceBeforeWithdrawals.lt(aliceBalanceAfterFirstWithdrawal)).to.be.true;
expect(aliceBalanceAfterFirstWithdrawal.lt(aliceBalanceAfterSecondWithdrawal)).to.be.true;
// O saldo do apartamento deve ser o seguinte:
// 1 ETH depois que Bob paga o aluguel
// 0.8 ETH após a retirada de Alice
// 1.8 ETH após Bob pagar o aluguel pela segunda vez
// 1.6 ETH após Alice retirar o aluguel pela segunda vez
expect((await apartment.balance()).eq(ethers.utils.parseEther("1.6"))).to.be.true;
})
Vamos ver a história em uma linha do tempo. Na imagem abaixo está a Alice que tem 15%, não 20% como no teste unitário, mas ainda assim é útil entender.
Caso de teste 11: A transferência de ações deve retirar os fundos atuais de ambas as partes
Nesta lição, há muito código para apresentar, então ele não será colado. Dê uma olhada no GitHub para ver o código-fonte exato da alteração
E agora vai ser o caso mais complicado. Pelo menos em termos de código. Do ponto de vista da lógica de negócios, é bastante fácil de entender. Sempre que houver a transferência de uma cota (operação que transfere algumas cotas de um apartamento de um usuário para outro) as duas partes afetadas devem ter seus fundos sacados imediatamente antes da ação de transferência de cotas.
Por quê? Apenas para facilitar toda a lógica, pois todos os fundos ganhos e não sacados até este ponto devem ser tratados de acordo com a divisão de ações antiga e todos os novos fundos ganhos após este ponto de acordo com a nova. Para indicar claramente o ponto de mudança, é melhor esclarecer as coisas e esquecer a velha realidade e, a partir de agora, pensar apenas na nova.
E, novamente, outro problema aqui pode ser que uma das partes envolvidas na transferência de ações possa de fato desconhecer essa operação. O destinatário não é necessário para fazer a transferência de ações e, na solução atual, esse usuário não apenas obterá algumas novas ações, mas também possivelmente obterá algum Ether inesperado.
Cronograma da linha do tempo abaixo:
Esta é uma apresentação simples da maneira de se pensar e trabalhar com alguns requisitos simples sobre os contratos. Isso está longe de ser todo o trabalho que precisa ser feito até que esse código possa ser implantado e atender os usuários de forma segura. Obrigado por chegar tão longe!
Recursos
Bons recursos para se aprofundar neste assunto são:
https://weka.medium.com/dividend-bearing-tokens-on-ethereum-42d01c710657
https://programtheblockchain.com/posts/2018/02/07/writing-a-simple-dividend-token-contract/
Publicado originalmente em https://www.rotynski.dev
Artigo original publicado por Piotr Rotyński. Traduzido por Paulinho Giovannini.
Oldest comments (0)