Staking: adicionando a funcionalidade num contrato de tokens fungíveis
Nesse artigo vamos incorporar a funcionalidade de staking num contrato inteligente que foi construído usando o protocolo ERC-20. Para quem não acompanhou, o processo foi detalhado no último post. Você pode também usar o código final que esta no meu Github por aqui.
Staking é a possibilidade de conseguir recompensas por travar algum tipo de token em um contrato inteligente.
Temos três razões principais pelas quais fazem sentido uma blockchain oferecer a possibilidade de staking. A primeira é que ao fazer staking de tokens em smart contracts você empresta sua liquidez (valor dos tokens) para atividades financeiras que geram retornos (empréstimos, seguros, corretoras) com o intuito de receber parte dos rendimentos gerados. A segunda é que, em alguns casos, os protocolos da blockchain fornecem aos proprietários de tokens staked o direito de voto em propostas de atualização na rede e dos protocolos. E a terceira é que a blockchain em questão pode botar os tokens para trabalhar a favor dela. Mas como assim, como isso funciona?
Pela natureza descentralizada da tecnologia blockchain não temos uma entidade centralizadora (como um banco ou o governo, por exemplo) garantindo que as transações entre partes sejam seguras e válidas. Para isso, todas as blockchains precisam contar com um mecanismo de consenso. Esse é um procedimento no qual os nós (“nodes”) de uma blockchain (cópias de seu estado) chegam a um acordo sobre o seu estado atual. Garantindo assim confiabilidade entre pares que se desconhecem. Em outras palavras, o mecanismo de consenso garante que cada novo bloco adicionado a blockchain seja a única versão da verdade acordada por todos os nós da rede.
Temos diferentes tipos de mecanismos de consenso como o Proof of Work, usado pelo Bitcoin, em que o minerador (validador de blocos) resolve um quebra-cabeça matemático complexo para a criação de um novo bloco. Esse quebra-cabeça exige um poder computacional elevado e, consequentemente, um consumo grande de energia. Desta forma, o nó que resolve o quebra-cabeça mais rapidamente é responsável pela geração de um novo bloco na rede e ganha uma recompensa por esse trabalho. O poder computacional necessário para validar novos blocos torna esse processo seguro, porém também o torna extremamente custoso.
A partir deste modelo, outros tipos de mecanismos de consenso começaram a ser desenvolvidos, como o Proof of Stake, que vamos abordar aqui. Nesse caso, ao invés de comprar um hardware caro (com alto consumo de energia) para resolver os quebra-cabeças, os validadores investem nas próprias criptomoedas da rede para participarem do processo de validação. Neste fomato, quando alocamos criptomoedas em staking, nós as estamos travando como garantia para validação de transações. Algo similar à função de um fiador na compra de um imóvel. Com isso, os usuários que tem criptomoedas em staking podem ser selecionados para serem os próximos validadores de uma transação, ganhando uma recompensa se ela for legitimada. Este sistema impede que validadores mal intencionados cometam uma fraude, pois perderão suas moedas ao serem descobertos por outros validadores.
Alguns exemplos de Blockchains que usam Proof of Stake como mecanismo de consenso são a Cosmos (ATOM), Cardano (ADA) e Solana (SOL). E a própria Ethereum está passando por uma atualização em que, além de outras mudanças, vai migrar sua rede para usar o Proof of Stake como mecanismo de consenso, o que promete oferecer uma renda passiva para seus detentores e é uma das primeiras etapas do roadmap para trazer uma redução nos elevados custos de gás nas transações que são vistas hoje em dia.
Mas de forma prática, como isso funciona em cada um desses exemplos de blockchains? Através de uma carteira como a Exodus, você consegue fazer staking de seus tokens ATOM, ADA e SOL. No caso da Solana e Cardano, você delega os seus tokens a uma Staking Pool. Nesse cenário, funciona como um botão de liga ou desliga. Você pode alocar todos os tokens que você possui na carteira em staking ou não. Com a diferença que, no caso da Cardano, você só pode pedir sua recompensa quando ela for maior ou igual a 1 ADA e, no caso da Solana, elas são automaticamente retornadas a cada 2 dias.
Já a Cosmos segue um padrão um pouco diferente, que é mais similar ao que vamos abordar nesse projeto. Você define a quantidade de tokens que você deseja alocar em staking e, uma vez alocados, você só pode retirá-los (unstake) após 21 dias. Porém, você também pode remover a quantidade de tokens que desejar. Além disso, é possível fazer o resgate das recompensas a qualquer momento.
1. Estrutura
Para quem não acompanhou o último post, estamos usando o Hardhat, um ambiente de desenvolvimento em Ethereum, nesse projeto. Nosso arquivo de diretórios atualmente tem a seguinte organização:
Onde Token é o contrato do nosso token fungível escrito seguindo o protocolo ERC-20. Vamos adicionar o arquivo Stakable.sol dentro da pasta contracts/ e fazendo sua formatação inicial.
Agora precisamos pensar na sua estrutura de dados e definir as variáveis necessárias para guardar as informações dos stakeholders (proprietários dos tokens em staking) e dos stakes.
Definimos primeiro duas structs (que são um grupo de variáveis relacionadas), uma para os stakes e outra para os stakeholders. Um stake vai ter o endereço do proprietário holder, a quantidade amount de tokens staked, a data de quando foi feito o stake stakedAt que vai servir para fazer o cálculo da recompensa, que é a quantidade de tokens que o proprietário tem direito a resgatar dado o tempo que ele manteve seus tokens congelados para staking.
O stakeholder guarda o endereço do proprietário que fez o stake na variável holder e um array de structs Stake stakes, que é lista de todos seus stakes ativos.
Além disso, temos um array de structs Stakeholder stakeholders que armazena a lista de proprietários que tem stakes ativos e um evento Staked que é emitido sempre que um proprietário alocar tokens para staking.
Agora que definimos a estrutura dos dados da nossa funcionalidade de staking, vamos começar a pensar em sua lógica. A princípio um proprietário de tokens vai poder:
- Fazer o stake de seus tokens (stake)
- Pedir a recompensa de seus tokens staked (claimReward)
- Retirar seus tokens em stake (unstake)
Então vamos desenhar a estrutura das funções internas que vão implementar essa lógica.
2. Lógica
Vamos começar pensando pela lógica de stake.
Vamos por partes porque muita coisa aconteceu aqui. Primeiro definimos um mapping stakeholdersIds que vai retornar o identificador atrelado ao endereço de um proprietário. Além disso, incluímos um item vazio no array de structs stakeholders para ser possível iniciar a contagem dos identificadores por 1.
constructor() {
stakeholders.push();
}...mapping(address => uint256) internal stakeholderIds;
Como fazemos um mapping de address para uint256, caso um endereço não tenha sido incluído, o retorno vai ser o valor default para o tipo de variável uint256, que é 0. Por isso na função _stake fazemos essa checagem e, caso seja realmente igual a 0, chamamos a função _addStakeholder.
uint256 stakeholderId = stakeholderIds[msg.sender];
uint256 stakedAt = block.timestamp;if (stakeholderId == 0) {
stakeholderId = _addStakeholder(msg.sender);
}
Note que a data do stake é mapeado pelo block.timestamp que contém o tempo em segundos desde a unix epoch (que é um meio para sistemas baseados em Unix fazerem cálculos temporais, dado pelo tempo em segundos desde 00:00:00 UTC on 1 January 1970). A função _addStakeholder vai incluir um item vazio no array de stakeholders para atualizar e retornar a numeração do identificador id a ser usado e depois vai atualizar o item para conter o endereço do proprietário holder. Em seguida atualiza também o mapping stakeholderIds e retorna o identificador id.
stakeholders.push();
uint256 id = stakeholders.length - 1;
stakeholders[id].holder = holder;
stakeholderIds[holder] = id;
return id;
A função _stake, por fim, inclui uma struct Stake no array stakes com os valores relacionados com o staking em questão e emite um novo evento Staked.
stakeholders[stakeholderId].stakes.push(Stake(msg.sender, amount, stakedAt, 0));
emit Staked(msg.sender, amount, stakeholderId, stakedAt);
Agora vamos desenvolver a lógica de retirada de recompensas com a função _claimReward. Para facilitar, só será possível que o proprietário retire todas as recompensas conquistadas desde o momento da última retirada ou, caso não tenha sido feita nenhuma retirada, desde o momento em que foram feitos os stakes. Outro ponto é que normalmente o percentual de recompensas gerado varia de acordo com o número de tokens em stake e com o número de transações acontecendo na rede. Como as recompensas são geradas quando uma transação é verificada com sucesso, quanto mais transações bem sucedidas, mais recompensas. Assim como, quanto mais tokens staked, mais proprietários para dividir as recompensas. Então o percentual de recompensas normalmente é diretamente proporcional ao número de transações e inversamente proporcional a quantidade de tokens em staking. Para facilitar mais uma vez, vamos pensar num percentual fixo de 10% APY (retorno anual), o que da algo em torno de 0,027% APD (retorno diário) e 0,78% APM (retorno mensal).
Então vamos começar a escrever o código de acordo com essa lógica.
Nós começamos a função _claimReward por checar se o endereço já foi adicionado como stakeholder, isto é, se já fez algum stake. Depois retornamos o identificador do proprietário stakeholderId e o total de stakes feitos até então por ele stakesCount. Em seguida, temos a função _retrieveReward que recebe o identificador e a contagem de stakes e, internamente, vai iterando por cada stake e calculando a recompensa dele com a função _calculateReward. Vamos analisar o retorno dessa função.
((((block.timestamp - stake.stakedAt) / 1 days) * stake.amount) / dailyPercentageYield);
Como mencionado anteriormente, o block.timestamp retorna uma numeração em segundos desde o unix epoch. E usamos também o time units 1 days que retorna o tempo em segundos de 24 horas. Multiplicamos esse valor pelo total de tokens alocados em staking. E depois dividimos pelo dailyPercentageYield que é o nosso APD.
uint256 dailyPercentageYield = 27000; // 0.027% = 1 / 27000
Em tese, deveríamos multiplicar pelo APD, mas dividimos para poder usar um APD inteiro, usando 27000 ao invés de 0.027. Esse site pode ser útil para realizar o teste com epochs reais e checar se o cálculo faz sentido. Vamos usar o epoch de 3 meses (1645884043), o de agora (1653573643), o tempo em segundos de 1 dia (86400), com 10000 tokens.
(((1653573643 - 1645884043) / 86400) * 10000)/27000 = 32,96
Como Solidity usa floored division (arredonda pra baixo resultados decimais) o reward nesse caso seria de 32 tokens.
Outro ponto a se notar é que após o calculo da recompensa total, alteramos a lista de stakes do proprietário para conter um único stake com total de tokens de todos stakes, alterando a data de stake para o momento atual e usando a função _emptyStakes para auxiliar nesse processo.
_emptyHolderStakes(stakeholderId, stakesCount);
stakeholders[stakeholderId].stakes.push(Stake(msg.sender, totalStaked, block.timestamp));
Agora temos nossa função para retornar as recompensas! Note que só é possível fazer o resgate total das recompensas. Nesse contrato não é possível fazer uma retirada em partes. Porém, vamos incluir a possibilidade de fazer um retirada parcial dos tokens em staking. Assim como é possível ir alocando recursos em stakes aos poucos. Vamos então codificar a lógica da nossa função _unstake.
A função _unstake tem um comportamento similar a função _claimReward até o ponto de retorno do total de recompensas e do total de tokens staked. Depois disso, checamos o valor que se deseja retirar. Se ele for maior que o total de tokens staked não vai ser retornado um erro, vamos retornar todos os tokens daquele proprietário em stake e reiniciar sua listagem de stakes com um stake nulo. Caso o total da retirada seja menor que o total em stake, a diferença voltará para stake. Em ambos casos a recompensa é retornada junto com o número de tokens staked retirados. Assim podemos reiniciar a listagem de stakes sem ter nenhum problema de inconsistência com as datas de stake. Vamos agora apenas adicionar uma função externa de leitura stakeReport que nos retorna o total em staking e as recompensas atuais de um proprietário.
Temos então nosso contrato de stake completo! :) O próximo passo vai ser integrá-lo ao nosso contrato de tokens fungíveis previamente desenvolvido.
Para herdar as funcionalidades de um contrato em outro precisamos fazer o import desse contrato e adicioná-lo em sua definição, da seguinte forma:
import "./Stakable.sol";contract Token is Ownable, Stakable {
...
}
Com isso, o contrato Token é capaz de herdar todas as funções definidas no contrato Stakable. Vamos agora incorporar essa lógica no nosso token fungível.
Note que como delegamos de maneira separada toda a lógica de staking para nosso contrato Stakable, o contrato Token não precisa saber detalhes de sua implementação, tornando a incorporação da lógica extremamente simples. Definimos as funções públicas stake, claimReward e unstake. E usamos as funções _mint e _burn previamente definidas para nos auxiliar no processo.
function stake(uint256 amount) public {
require(amount <= _balances[msg.sender], "Token: cannot stake more than you own");
_stake(amount);
_burn(msg.sender, amount);
}function claimReward() public {
uint256 reward = _claimReward();
_mint(msg.sender, reward);
}function unstake(uint256 amount) public {
uint256 stakes = _unstake(amount);
_mint(msg.sender, stakes);
}
3. Testes
Agora vamos escrever os testes para checar se as novas funcionalidades estão funcionando como deveriam. Vamos começar criando um arquivo tokenStake.js no diretório de testes test/.
No nosso primeiro teste checamos se o dono do contrato, que inicialmente começa com os primeiros 5M tokens mintados consegue efetuar um stake corretamente de 100k tokens.
Podemos ver que o teste passa com sucesso! Uma dica boa é usar o seguinte comando para rodar apenas os testes de stake:
npx hardhat test --grep stake
Incluímos mais dois testes, um para checar se a transação é revertida quando o usuário tenta fazer um stake de uma quantidade de tokens maior que possui. O seguinte para checar se esse mesmo usuário consegue fazer corretamente o stake de uma quantidade de tokens inferior ou igual a que possui.
Agora vamos fazer um teste interessante, vamos checar a evolução das recompensas com o tempo. Para isso, precisamos rodar os seguintes comandos para indicar a EVM que nós estamos fazendo um avanço no timestamp.
await ethers.provider.send("evm_increaseTime", [15780000]);
await ethers.provider.send("evm_mine");
Simulamos assim criação de novos blocos. Podemos usar também o evm_setNextBlockTimestamp
a diferença dele para o evm_increaseTime
é que no primeiro definimos o próximo timestamp e no segundo escolhemos quanto tempo em segundos avançar. No caso avançamos o equivalente a 6 meses em segundos.
Podemos checar que os testes funcionaram corretamente e que o valor de recompensas do dono do contrato (que fez um stake maior) é maior que a do usuário “lucas” após 6 meses.
Vamos agora avançar um pouco com os testes e incluir checagens para as funções claimReward e unstake.
Nos testes a seguir testamos se um proprietário é capaz de pedir suas recompensas corretamente e depois testamos a retirada de parte e de todos os tokens staked. Em ambos casos, checamos se o total de tokens disponíveis e se os saldos dos proprietários foram atualizados corretamente. Podemos notar que todos os testes passam como o esperado.
Em seguida testamos todos os casos testes para certificar que todos continuam passando como o esperado e para ter uma noção dos custos gerais do projeto.
Temos então um token fungível construído usando o protocolo ERC-20 com a funcionalidade de staking totalmente funcional! :)
O código completo do projeto está no meu Github.
Se esse tutorial foi útil pra você não se esquece de soltar o clap e me seguir aqui pra acompanhar os próximos posts! Me ajuda muito :)