Neste tutorial de Solidity intermediário, estarei construindo, testando e implantando um contrato inteligente para reequilibrar um portfólio de ativos digitais. A ideia é ver como podemos trabalhar com contratos inteligentes externos para começar a construir nossos próprios produtos nos blocos DeFi Lego.
- O Desafio
- Tutorial de Solidity Intermediário [Vídeo]
- O Ambiente de Desenvolvimento
- Guia de Início Rápido
- Tutorial do Código Solidity
- Testando Contratos Inteligentes Solidity
- Implantando e Utilizando o Hardhat
- Verificações de Segurança do Solidity
O Desafio
Criar um contrato inteligente Solidity para manter e reequilibrar um portfólio de ativos digitais semelhante a um portfólio de criptomoedas 60/40 sobre o qual falei no passado.
Isso permitirá que os fundos sejam enviados ao contrato pelo proprietário, momento em que podem ser reequilibrados, chamando uma função do contrato. Isso executará uma transação em uma exchange descentralizada, especificamente Uniswap v3.
Precisaremos de alguns feeds de preços de oráculos externos e uma maneira de retirar fundos do cofre.
Este é um tutorial de Solidity intermediário que fluirá relativamente rápido e cobrirá muitos dos conceitos básicos de Solidity abordados neste tutorial introdutório: https://jamesbachini.com/solidity-tutorial/
Vídeo Tutorial de Solidity Intermediário
O Ambiente de Desenvolvimento
Decidi usar o Hardhat em vez do Truffle neste tutorial, pois sinto que ele fornece uma estrutura rica em recursos e não fiz um tutorial com ele antes. Você também precisará de NodeJS, Metamask e uma chave da API Alchemy.
npm install hardhat
Meu arquivo de configuração do Hardhat se parece com isso:
require("@nomiclabs/hardhat-waffle");
require("@nomiclabs/hardhat-etherscan");
const ethers = require('ethers');
const credentials = require('./credentials.js');
/**
* @type import('hardhat/config').HardhatUserConfig
*/
module.exports = {
solidity: "0.8.4",
networks: {
kovan: {
url: `https://eth-kovan.alchemyapi.io/v2/${credentials.alchemy}`,
accounts: [credentials.privateKey],
},
local: {
url: `http://127.0.0.1:8545/`,
accounts: [credentials.privateKey],
},
},
etherscan: {
apiKey: credentials.etherscan
}
};
Tudo bem padrão, observe que estou armazenando a chave privada da testnet (com fundos) e as chaves da API Alchemy em um arquivo chamado credenciais.js que não está incluído no repositório GitHub.
Falando nisso, o código completo pelo qual passaremos está aqui:- https://github.com/jamesbachini/myVault
Podemos clonar este repositório usando o seguinte comando:
git clone https://github.com/jamesbachini/myVault.git
Guia de Início Rápido
Os comandos a seguir criarão, testarão e implantarão os contratos.
git clone https://github.com/jamesbachini/myVault.git
cd myVault
mv credentials-example.js credentials.js
code credentials.js (Insira o endereço da carteira da testnet Kovan com fundos ETH e chaves API Alchemy/Etherscan)
npm install
npx hardhat compile
npx hardhat node --fork https://eth-kovan.alchemyapi.io/v2/YourAlchemyAPIKeyHere
npx hardhat test --network local
npx hardhat run scripts/deploy.js --network kovan
Se não funcionar ou não fizer sentido, continue lendo.
Tutorial do Código Solidity
Começamos definindo a versão da licença e do Solidity. Observe que estamos usando a versão 8 ou superior. O Solidity v8 ou superior é significativo do ponto de vista da segurança, pois a equipe introduziu uma série de verificações para evitar overflows de integers. Esta é a razão pela qual não estou usando bibliotecas Safemath em todo o código.
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
Em seguida, importamos algumas bibliotecas do OpenZeppelin e UniswapV3.
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import '@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol';
import '@uniswap/v3-periphery/contracts/interfaces/IQuoter.sol';
Finalmente e por último, antes de ficarmos presos no contrato, vamos precisar de algumas interfaces: 1. Uma para um oráculo Chainlink obter o preço do ETH. 2. Uma para retornar qualquer ETH restante das transações Uniswap. 3. A interface final é para adicionar uma função de depósito à interface ERC20 padrão a fim de converter ETH > WETH.
// EACAggregatorProxy é usado para oráculo Chainlink
interface EACAggregatorProxy {
function latestAnswer() external view returns (int256);
}
// Interface Uniswap v3
interface IUniswapRouter is ISwapRouter {
function refundETH() external payable;
}
// Adiciona função de depósito para WETH
interface DepositableERC20 is IERC20 {
function deposit() external payable;
}
Observe que podemos descobrir como declarar funções para interfaces em contratos externos usando o código verificado no Etherscan.
O contrato é então definido e alguns endereços são codificados rigidamente no contrato. Eles também podem ser fornecidos para a função constructor, o que seria mais limpo ao mover para a rede principal, mas neste exemplo o código não está chegando perto da rede principal, portanto, codificá-los rigidamente no contrato é aceitável.
contract myVault {
uint public version = 1;
/* Endereços Kovan*/
address public daiAddress = 0x4F96Fe3b7A6Cf9725f59d353F723c1bDb64CA6Aa;
address public wethAddress = 0xd0A1E359811322d97991E03f863a0C30C2cF029C;
address public uinswapV3QuoterAddress = 0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6;
address public uinswapV3RouterAddress = 0xE592427A0AEce92De3Edee1F18E0157C05861564;
address public chainLinkETHUSDAddress = 0x9326BFA02ADD2366b30bacB125260Af641031331;
Você pode usar a Metamask e o Etherscan para encontrar a maioria desses endereços. Basta realizar uma transação na versão testnet do Uniswap v3 e seguir os rastros da transação no Etherscan. Há uma grande lista de endereços de feeds de dados da Chainlink aqui: https://docs.chain.link/docs/ethereum-addresses/
Observe que estamos usando WETH aqui, que é uma versão encapsulada do ETH. Como o ETH é a moeda/token nativo da rede principal da Ethereum, ele não se comporta da mesma maneira que um token ERC20. Portanto, o ETH encapsulado é apenas um contrato de token ERC20 no qual você pode depositar ETH e obter WETH, um ERC20 compatível atrelado a 1:1 com ETH, em troca.
O próximo passo é definir algumas variáveis de estado.
uint public ethPrice = 0;
uint public usdTargetPercentage = 40;
uint public usdDividendPercentage = 25; // 25% de 40% = 10% de saque anual
uint private dividendFrequency = 5 minutes; // mude para 1 ano para produção
uint public nextDividendTS;
address public owner;
E então algumas interfaces e um evento de log básico.
using SafeERC20 for IERC20;
using SafeERC20 for DepositableERC20;
IERC20 daiToken = IERC20(daiAddress);
DepositableERC20 wethToken = DepositableERC20(wethAddress);
IQuoter quoter = IQuoter(uinswapV3QuoterAddress);
IUniswapRouter uniswapRouter = IUniswapRouter (uinswapV3RouterAddress);
event myVaultLog(string msg, uint ref);
O código acima configura uma interface SafeERC20 para o daiToken e para o wethToken. Observe que a interface WETH é configurada usando a interface DepositableERC20 que criamos anteriormente com a função de depósito extra. Em seguida, configuramos uma interface de cotação para obter dados de preços do Uniswap e uma interface uniswapRouter para fazer as trocas de tokens.
Vamos agora criar uma função constructor que será acionada apenas uma vez enquanto o contrato estiver sendo implantado.
constructor() {
console.log('Implantando myVault Versão:', version);
nextDividendTS = block.timestamp + dividendFrequency;
owner = msg.sender;
}
Observe que definimos duas variáveis na função constructor
- A variável nextDividendTS será a data futura em que uma retirada pode ocorrer. Medido como um timestamp Unix, que é o número de segundos desde 1º de janeiro de 1970
- Com a variável owner definiremos o endereço do proprietário para o endereço que implantou o contrato e pagou as taxas de gas. Este será o mesmo endereço que está em credentials.js
Eu quero poder obter o saldo da conta de WETH e DAI, então vamos criar funções para isso.
function getDaiBalance() public view returns(uint) {
return daiToken.balanceOf(address(this));
}
function getWethBalance() public view returns(uint) {
return wethToken.balanceOf(address(this));
}
Eu também quero poder obter o valor total em USD da conta (o que exigirá um oráculo de preço ETH que ainda não foi configurado).
function getTotalBalance() public view returns(uint) {
require(ethPrice > 0, 'Valor do ETH ainda não foi definido');
uint daiBalance = getDaiBalance();
uint wethBalance = getWethBalance();
uint wethUSD = wethBalance * ethPrice; // assume que ambos os ativos têm 18 decimais
uint totalBalance = wethUSD + daiBalance;
return totalBalance;
}
Este é o primeiro exemplo de uma instrução require, que verifica se definimos o ethPrice usando um serviço de oráculo antes da getTotalBalance() ser chamada.
Então, vamos criar algumas funções diferentes para interagir com os feeds de dados do oráculo. O primeiro usará a interface de cotação da Uniswap para obter um preço diretamente da exchange descentralizada. O segundo usará o feed de dados de preços da Chainlink, que simplesmente cita um valor em dólares para o ETH.
function updateEthPriceUniswap() public returns(uint) {
uint ethPriceRaw = quoter.quoteExactOutputSingle(daiAddress,wethAddress,3000,100000,0);
ethPrice = ethPriceRaw / 100000;
return ethPrice;
}
function updateEthPriceChainlink() public returns(uint) {
int256 chainLinkEthPrice = EACAggregatorProxy(chainLinkETHUSDAddress).latestAnswer();
ethPrice = uint(chainLinkEthPrice / 100000000);
return ethPrice;
}
É bastante útil na testnet ter dois oráculos de preços diferentes porque a falta de arbitragem na testnet DEX significa que eles fornecerão respostas totalmente diferentes, o que é ótimo para testar os efeitos dos movimentos de preços.
Agora queremos criar uma função para trocar DAI por WETH usando a função exactInputSingle do Uniswap V3.
function buyWeth(uint amountUSD) internal {
uint256 deadline = block.timestamp + 15;
uint24 fee = 3000;
address recipient = address(this);
uint256 amountIn = amountUSD; // inclui 18 decimais
uint256 amountOutMinimum = 0;
uint160 sqrtPriceLimitX96 = 0;
emit myVaultLog('amountIn', amountIn);
require(daiToken.approve(address(uinswapV3RouterAddress), amountIn), 'Aprovação DAI falhou');
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams(
daiAddress,
wethAddress,
fee,
recipient,
deadline,
amountIn,
amountOutMinimum,
sqrtPriceLimitX96
);
uniswapRouter.exactInputSingle(params);
uniswapRouter.refundETH();
}
Deadline (prazo) é definido como 15 segundos a partir do timestamp do bloco. Fee (taxa) é definida para o pool padrão de 0,3%. AmountIn (Montante) inclui os 18 decimais para o DAI e é um montante de USD em ETH para comprar o WETH. Não há uma definição de retorno mínimo, o que abre para a possibilidade de derrapagem e front running. Novamente, isso não é um problema na rede de testes Kovan.
Em seguida, emitimos uma entrada ao log, para o valor amountIn, principalmente para depuração.
O código passa a aprovar o endereço do contrato do roteador Uniswap para gastar o valor exato de tokens DAI. Então, finalmente, executamos a troca dos tokens.
O próximo trecho de código faz a mesma coisa, mas vende WETH por DAI usando a função exactOutputSingle.
function sellWeth(uint amountUSD) internal {
uint256 deadline = block.timestamp + 15;
uint24 fee = 3000;
address recipient = address(this);
uint256 amountOut = amountUSD; // inclui 18 decimais
uint256 amountInMaximum = 10 ** 28 ;
uint160 sqrtPriceLimitX96 = 0;
require(wethToken.approve(address(uinswapV3RouterAddress), amountOut), 'Aprovação WETH falhou');
ISwapRouter.ExactOutputSingleParams memory params = ISwapRouter.ExactOutputSingleParams(
wethAddress,
daiAddress,
fee,
recipient,
deadline,
amountOut,
amountInMaximum,
sqrtPriceLimitX96
);
uniswapRouter.exactOutputSingle(params);
uniswapRouter.refundETH();
}
Agora que temos essas funções de compra/venda, podemos criar uma função para reequilibrar o portfólio para as porcentagens de 60/40.
function rebalance() public {
require(msg.sender == owner, "Somente o proprietário pode reequilibrar sua conta");
uint usdBalance = getDaiBalance();
uint totalBalance = getTotalBalance();
uint usdBalancePercentage = 100 * usdBalance / totalBalance;
emit myVaultLog('usdBalancePercentage', usdBalancePercentage);
if (usdBalancePercentage < usdTargetPercentage) {
uint amountToSell = totalBalance / 100 * (usdTargetPercentage - usdBalancePercentage);
emit myVaultLog('amountToSell', amountToSell);
require (amountToSell > 0, "Nada para vender");
sellWeth(amountToSell);
} else {
uint amountToBuy = totalBalance / 100 * (usdBalancePercentage - usdTargetPercentage);
emit myVaultLog('amountToBuy', amountToBuy);
require (amountToBuy > 0, "Nada para comprar");
buyWeth(amountToBuy);
}
}
Observe que ao calcular porcentagens, usamos uma fórmula de trás para frente para evitar decimais devido à declaração de tipo unsigned integer. Os integers só podem lidar com números inteiros.
Também vou criar uma função para sacar um dividendo anual de 10% da estratégia. Isso seria útil se estivesse sendo configurado como um fundo fiduciário perpétuo ou portfólio de contribuições de caridade.
function annualDividend() public {
require(msg.sender == owner, "Somente o proprietário pode sacar da sua conta");
require(block.timestamp > nextDividendTS, 'O dividendo ainda não é devido');
uint balance = getDaiBalance();
uint amount = (balance * usdDividendPercentage) / 100;
daiToken.safeTransfer(owner, amount);
nextDividendTS = block.timestamp + dividendFrequency;
}
A função permite ao proprietário sacar 25% do saldo de DAI. O método daiToken.safeTransfer() é usado para enviar fundos ERC20 para a carteira do proprietário. Usamos a variável block.timestamp para atualizar quando o próximo dividendo vence.
Para fins de teste, também quero uma maneira de fechar a conta e remover todos os fundos.
function closeAccount() public {
require(msg.sender == owner, "Somente o proprietário pode encerrar sua conta");
uint daiBalance = getDaiBalance();
if (daiBalance > 0) {
daiToken.safeTransfer(owner, daiBalance);
}
uint wethBalance = getWethBalance();
if (wethBalance > 0) {
wethToken.safeTransfer(owner, wethBalance);
}
}
Isso transfere os saldos de DAI e WETH para o proprietário.
A última coisa que queremos fazer é permitir que o ETH seja enviado ao contrato e convertido em WETH.
receive() external payable {
}
function wrapETH() public {
require(msg.sender == owner, "Somente o proprietário pode converter ETH para WETH");
uint ethBalance = address(this).balance;
require(ethBalance > 0, "Nenhum ETH disponível para encapsulamento");
emit myVaultLog('wrapETH', ethBalance);
wethToken.deposit{ value: ethBalance }();
}
}
Portanto, temos uma função de pagamento padrão que aceitará ETH no contrato, mas não fará nada com isso. A razão pela qual não chamamos a wrapETH diretamente é porque isso quebraria a maioria das transferências de ETH ao estourar o limite de gas. Ao enviar ETH via Metamask, por exemplo, um limite de gas bem apertado é definido por ela, o que não permite que o contrato faça muito na mesma transação.
No final da função wrapETH(), interagimos com a função de depósito personalizado do wethToken para encapsular todo o saldo ETH em WETH.
Finalmente fechamos os colchetes do contrato. Pronto!
Se você quiser revisar todo o código do contrato, ele está disponível aqui:
https://github.com/jamesbachini/myVault/blob/main/contracts/myVault.sol
Testando contratos inteligentes Solidity
Agora vamos escrever alguns testes usando hardhat e chai.
const hre = require('hardhat');
const assert = require('chai').assert;
describe('myVault', () => {
let myVault;
beforeEach(async function () {
const contractName = 'myVault';
await hre.run("compile");
const smartContract = await hre.ethers.getContractFactory(contractName);
myVault = await smartContract.deploy();
await myVault.deployed();
console.log(`${contractName} implantado em: ${myVault.address}`);
});
it('Deve retornar a versão correta', async () => {
const version = await myVault.version();
assert.equal(version,1);
});
});
Este é o teste mais simples que podemos escrever, que verifica se a versão da variável pública está definida como 1. Podemos executá-lo usando o seguinte comando.
npx hardhat test
Muitas de nossas funções de contrato estão chamando funções externas de outros contratos. Para testar estas funções, precisaremos implantar na rede Kovan e testar lá (processo lento) ou podemos fazer um fork do estado da rede Kovan localmente.
npx hardhat node --fork
https://eth-kovan.alchemyapi.io/v2/AlchemyAPIkeyHere
Isso irá configurar um nó local que está executando um fork independente da rede de teste kovan.
Agora podemos expandir os testes para chamar as funções externas de outros contratos.
it('Deve retornar saldo zero de DAI', async () => {
const daiBalance = await myVault.getDaiBalance();
assert.equal(daiBalance,0);
});
Este teste entrará em contato com o contrato daiToken externo em 0x4F96Fe3b7A6Cf9725f59d353F723c1bDb64CA6Aa e verificará o saldo do nosso endereço de contrato. Vamos testar isso usando o ambiente local que estruturamos na configuração do Hardhat, que se conecta ao nó local que estamos executando.
npx hardhat test --network local
Se fôssemos implantar isso na rede principal, precisaríamos escrever testes de unidade para cada função e criar alguns testes amplos de funcionalidade do contrato. O Hardhat se destaca em métodos avançados de script, onde podemos movimentar fundos e acionar diferentes contratos, etc.
Aqui está um exemplo de teste mais avançado, onde podemos enviar 0,01 ETH da conta do proprietário para o contrato, depois fazer o encapsulamento para WETH, atualizar o preço do ETH usando o oráculo Uniswap e reequilibrar o portfólio antes de verificar se o saldo em DAI está acima de zero.
it('Deve reequilibrar o portfólio ', async () => {
const accounts = await hre.ethers.getSigners();
const owner = accounts[0];
console.log('Transferindo ETH do endereço do proprietário', owner.address);
await owner.sendTransaction({
to: myVault.address,
value: ethers.utils.parseEther('0.01'),
});
await myVault.wrapETH();
await myVault.updateEthPriceUniswap();
await myVault.rebalance();
const daiBalance = await myVault.getDaiBalance();
console.log('Saldo DAI reequilibrado',daiBalance);
assert.isAbove(daiBalance,0);
});
Implantando e Utilizando o Hardhat
Assim que tivermos alguns testes aceitáveis a postos, estaremos prontos para implantar na testnet externa Kovan e começar a mexer com ela no Etherscan. Um recurso muito bom do Hardhat é que podemos verificar o contrato no Etherscan a partir do script de implantação.
const hre = require('hardhat');
const fs = require('fs');
async function main() {
const contractName = 'myVault';
await hre.run("compile");
const smartContract = await hre.ethers.getContractFactory(contractName);
const myVault = await smartContract.deploy();
await myVault.deployed();
console.log(`${contractName} implantado em: ${myVault.address}`);
const contractArtifacts = await artifacts.readArtifactSync(contractName);
fs.writeFileSync('./artifacts/contractArtifacts.json', JSON.stringify(contractArtifacts, null, 2));
await hre.run("verify:verify", {
address: myVault.address,
//constructorArguments: [],
});
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
O script começa compilando o contrato. Em seguida, ele o implanta na rede Kovan e registra o novo endereço do contrato. Em seguida, gravamos os artefatos do contrato em um arquivo contractArtifacts.json que será útil ao fazer o trabalho de front-end. Finalmente, verificamos o contrato no Etherscan.
Podemos executar este script usando o seguinte comando:
npx hardhat run scripts/deploy.js --network kovan
Podemos então usar e esperar para verificar o contrato no Etherscan e depois gerar um link para:
https://kovan.etherscan.io/address/0x9e87D719Ad4304731915C5bc5D2304D38E618b7D#code
Onde podemos interagir com o contrato usando a carteira do proprietário na Metamask.
Para implantar na rede principal Ethereum ou em qualquer sidechain compatível com camada 2 ou EVM, podemos simplesmente adicionar uma rede na configuração do Hardhat e alterar os endereços. Observe que o endereço do contrato para o token DAI ou o roteador Uniswap será diferente em cada rede.
Verificações de Segurança do Solidity
Além de tudo, gostaríamos de realizar intensas verificações de segurança e, idealmente, ter o código auditado por terceiros antes de confiar nele para transações financeiras. Uma ferramenta chamada Slither pode ser bastante útil para auditorias de vulnerabilidades simples. É um pouco como o ESlint é para o Solidity.
Eu só consegui executá-lo no Linux e usar o Ubuntu no WSL, instalando através dos seguintes comandos em um linha de comando Linux:
sudo add-apt-repository ppa:ethereum/ethereum
sudo apt-get update
sudo apt install solc
sudo pip3 install slither-analyzer
slither
cd /mnt/c/shareddocs/jamesbachini/code/myVault/
sudo npm install
sudo slither .
Aqui estão alguns dos muitos problemas destacados no contrato inteligente myVault do Slither. Muitos deles são falsos positivos e problemas com contratos de terceiros importados, mas todos precisam ser verificados antes de serem implantados na rede principal.
Poderíamos levar isso adiante e configurar um fuzzer como o Echidna para usar de força bruta com dados incomuns nas funções do contrato. Idealmente, no entanto, se os orçamentos permitirem, é benéfico obter um auditor de segurança terceirizado para examinar o código e destacar problemas complexos que podem ter sido ignorados pelos desenvolvedores originais.
Espero que este tutorial intermediário de Solidity tenha se mostrado interessante e que você não tenha detectado muitos bugs no código myVault ao longo do caminho.
Interessado em aprender mais sobre desenvolvimento de blockchain, DeFi e manter-se atualizado com os mercados de criptomoedas? Confira o canal do YouTube e conecte-se comigo no Twitter para atualizações e novos conteúdos.
https://www.YouTube.com/c/JamesBachini
https://Twitter.com/james_bachini
Se você gostou desses recursos, por favor ajude a compartilhar este conteúdo nas mídias sociais e envie para quem você acha que pode se beneficiar com ele.
Obrigado.
Isenção de responsabilidade: O conteúdo que crio é para documentar minha jornada e para fins de entretenimento. Não é em hipótese alguma um conselho de investimento. Eu não sou um profissional de investimentos ou negociações e estou aprendendo sozinho enquanto ainda cometo muitos erros ao longo do caminho. Qualquer código publicado é experimental e não está pronto para ser usado em transações financeiras. Faça suas próprias pesquisas e não se arrisque com fundos que você não quer perder.
Este artigo foi escrito por James Bachini, e traduzido por Paulinho Giovannini. Veja o artigo original aqui.
Top comments (0)