Recentemente escrevi um artigo aqui no blog explicando o que são e para que servem os blockchain oracles, elementos importantíssimos da arquitetura de soluções web3 que necessitam de dados off-chain no mundo on-chain. No tutorial de hoje, quero mostrar como você pode implementar um oracle, na prática, utilizando Solidity para o smart contract e Node.js para o backend off-chain.
Vamos lá!
#1 – Contrato de Storage Oracle
Nosso oráculo vai usar a arquitetura Storage Oracle, ou seja, onde de tempos em tempos, o dado offchain é inserido em uma smart contract do oráculo, permitindo que qualquer outro smart contract possa acessar essa informação. Como dito no artigo anterior, essa abordagem é a que proporciona a melhor experiência para os consumers, mas dependendo da natureza da informação (ou seja, do quanto ela precisa estar atualizada) pode gerar grandes custos para o owner.
Usaremos a linguagem Solidity aqui, compatível com todas blockchains EVM-based como Ethereum e outras. Você pode aprender Solidity neste artigo, caso nunca tenha programado com ela antes.
Como exemplo, vamos fazer um oráculo de jogos de futebol brasileiros, algo útil para bets descentralizadas, onde os ganhadores das apostas são definidos por resultados dos jogos. Para isso, nossos Storage Contract deve ter estruturas de dados capazes de armazenar os resultados dos jogos e fornecer acesso aos mesmos facilmente, a quem quer que precise dessa informação. Vou criar este contrato no Remix, mas em um projeto profissional, recomendo usar o toolkit HardHat. Explicação a seguir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// SPDX-License-Identifier: MIT pragma solidity ^0.8.26; struct Game { uint timestamp; string team1; uint8 score1; string team2; uint8 score2; uint8 winner; } contract StorageOracle { } |
Primeiro eu comecei definindo a struct que representa uma partida de futebol. Ela contém um timestamp, que é quando o jogo terminou e foi registrado na blockchain, o código dos times 1 e 2 (geralmente 3 letras, como GRE para Grêmio), o número de goles de cada time (score1 e 2) e o vencedor da partida (quando houver). A ideia é que associado a cada jogo, seja criado um id de jogo seguindo a convenção “TEAM1XTEAM2YYYYMMDDHHMM” ou seja, um jogo de Grêmio contra Internacional, no dia 31/08/2024 às 20h teria o id “GREXINT202408312000”. Isso garante que cada jogo tenha um id determinístico e único, já que dois times não se enfrentam no mesmo dia em mais de um jogo.
Agora vamos começar escrevendo nosso contrato em si.
1 2 3 4 5 6 7 8 9 10 11 |
contract StorageOracle { address immutable owner; //gameId = "TEAM1XTEAM2YYYYMMDDHHMM" mapping(bytes32 => Game) allGames; constructor() { owner = msg.sender; } } |
Aqui eu declarei uma variável de estado para armazenar quem é o owner do contrato. O uso de immutable nela é para que seu valor possa ser definido apenas uma vez, no seu construtor (ou seja, quando o contrato for deployado). Essa informação é importante para garantir que algumas tarefas administrativas possam ser realizadas apenas pelo owner.
Depois, declarei o mapping que vai guardar os resultados de todos jogos usando como id um bytes32 referente ao hash da string de gameId, com o padrão mencionado anteriormente e no comentário acima do mesmo. Cada gameId vai estar apontando para um objeto do tipo Game, explicado no tópico anterior.
Agora vamos às funções do contrato.
1 2 3 4 5 6 7 8 9 10 |
function getGame(string calldata gameId) external view returns (Game memory) { return allGames[keccak256(abi.encodePacked(gameId))]; } function setGame(string calldata gameId, Game calldata newGame) external { require(msg.sender == owner, "Unauthorized"); allGames[keccak256(abi.encodePacked(gameId))] = newGame; } |
Precisaremos de duas funções, uma getGame que dado um gameId retorna os dados daquele jogo (apenas repare como converto string para bytes32, a fim de termos a chave do mapping) e outra setGame, que serve para o owner poder registrar os dados de cada jogo.
Note que a função do storage oracle é somente essa, ele não tem qualquer regra de negócio referente ao que pode ser feito com estes dados. Opcionalmente você poderia ter mais de um owner, cada um deles representando uma fonte da informação e somente quando a maioria dos owners registrassem a informação igual sobre o mesmo jogo é que ela passaria a ser pública. Essa é uma técnica bem comum entre oráculos de alta confiança, para garantir aos consumers que eles não estão sendo manipulados, dando mais segurança ao processo.
Agora que temos o contrato de storage oracle, vamos criar o backend do oráculo, que vai alimentá-lo com os dados off-chain. É importante que antes de avançar, que você faça o deploy do seu contrato na blockchain (pode ser testnet ou até mesmo local). Eu fiz o meu na BNB/BSC Testnet usando uma carteira MetaMask e após o deploy, guardei o endereço gerado para o contrato, pois vamos precisar dele mais adiante.
Não sabe criar e configurar uma carteira MetaMask? Ensino abaixo.
Você vai precisar também do ABI do seu smart contract (Application Binary Interface) que você obtém assim que faz a compilação dele no Remix, como mostra a imagem abaixo (só clicar onde diz “ABI”).
#2 – Backend Off-Chain
O backend off-chain de um oráculo de blockchain varia enormente dependendo da natureza do mesmo. Quando me refiro a variar quero dizer sobre tecnologias, arquitetura, fontes de dados e até regras de negócio sobre os dados. Desta forma, vou trazer aqui apenas um exemplo didático de como um backend desses poderia ser, mas sem a intenção de esgotar o assunto.
Primeiro, usarei a tecnologia Node.js para a sua construção. Caso nunca tenha programado Node.js antes, tem muitos tutoriais aqui no blog que ensinam, além de livros e um curso que produzi. Não vou me preocupar aqui com segurança, algo que toma muito tempo e que é necessário em um backend real, vou me ater apenas ao funcionamento mais básico. Também não vou me preocupar com a confiabilidade dos dados, teremos o backend sendo acessado e utilizado por apenas um agente, ou seja, poderia facilmente ser manipulado pois a fonte de informação é única.
Dito isso, crie uma pasta para o seu protótipo de backend com o nome de storage-oracle-backend e dentro dela rode o comando abaixo para inicializar o projeto e instalar as dependências que vamos precisar.
1 2 3 |
npm install express ethers dotenv |
A saber:
- Express: webframework que vamos utilizar para criar o backend;
- Ethers: biblioteca web3 que vamos usar para comunicação com a bockchain;
- DotEnv: biblioteca para carregamento das configurações do backend;
Com as dependências instaladas, crie um arquivo .env na raiz da sua aplicação com as seguintes variáveis preenchidas de acordo com as instruções.
- PORT: a porta onde seu backend irá subir. Ex: 3000
- BLOCKCHAIN_NODE: o endereço do nó RPC da blockchain que fez deploy do seu contrato. Ex: https://data-seed-prebsc-1-s1.binance.org:8545/ (BNB Testnet)
- CONTRACT_ADDRESS: o endereço do seu contrato StorageOracle.sol na blockchain;
- PRIVATE_KEY: a chave privada da sua carteira cripto utilizada no deploy;
Crie um arquivo abi.json na raiz do projeto e cole nele o conteúdo do ABI obtido na compilação do contrato, como abaixo.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
[ { "inputs": [], "stateMutability": "nonpayable", "type": "constructor" }, { "inputs": [ { "internalType": "string", "name": "gameId", "type": "string" } ], "name": "getGame", "outputs": [ { "components": [ { "internalType": "uint256", "name": "timestamp", "type": "uint256" }, { "internalType": "string", "name": "team1", "type": "string" }, { "internalType": "uint8", "name": "score1", "type": "uint8" }, { "internalType": "string", "name": "team2", "type": "string" }, { "internalType": "uint8", "name": "score2", "type": "uint8" }, { "internalType": "uint8", "name": "winner", "type": "uint8" } ], "internalType": "struct Game", "name": "", "type": "tuple" } ], "stateMutability": "view", "type": "function" }, { "inputs": [ { "internalType": "string", "name": "gameId", "type": "string" }, { "components": [ { "internalType": "uint256", "name": "timestamp", "type": "uint256" }, { "internalType": "string", "name": "team1", "type": "string" }, { "internalType": "uint8", "name": "score1", "type": "uint8" }, { "internalType": "string", "name": "team2", "type": "string" }, { "internalType": "uint8", "name": "score2", "type": "uint8" }, { "internalType": "uint8", "name": "winner", "type": "uint8" } ], "internalType": "struct Game", "name": "newGame", "type": "tuple" } ], "name": "setGame", "outputs": [], "stateMutability": "nonpayable", "type": "function" } ] |
Com as variáveis de ambiente e ABI posicionados, crie um arquivo index.js na raiz do seu projeto e vamos começar configurando ele para comunicação com nosso contrato.
1 2 3 4 5 6 7 8 9 |
require("dotenv").config(); const { ethers } = require("ethers"); const ABI = require("./abi.json"); const provider = new ethers.JsonRpcProvider(process.env.BLOCKCHAIN_NODE); const wallet = new ethers.Wallet(`${process.env.PRIVATE_KEY}`, provider); const contract = new ethers.Contract(process.env.CONTRACT_ADDRESS, ABI, wallet); |
Começamos carregando as variáveis de ambiente com a chamada ao DotEnv. Depois carregamos a lib EthersJS, o ABI do nosso contrato e configuramos nosso provedor Json RPC, apontado para o nó blockchain da rede onde fizemos deploy.
Com o provider configurado, configuramos na sequência a nossa carteira a partir de sua chave privada e apontada para o provider anteriormente configurado.
Por fim, configuramos o objeto de comunicação com o contrato na blockchain, a partir do seu endereço, ABI e nossa carteira.
Agora é a hora de avançarmos no mesmo index.js para programar o nosso backend em si. Como dito anteriormente, ele não terá qualquer segurança e apenas um endpoint para que o owner envie as informações de um jogo de futebol que acabou de ser finalizado.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
const express = require("express"); const app = express(); app.use(express.json()); app.post("/", async (req, res) => { const { gameId, newGame } = req.body; const tx = await contract.setGame(gameId, { ...newGame, timestamp: Date.now() / 1000 }); await tx.wait(); res.json(tx); }) app.listen(process.env.PORT, () => console.log("Server is running.")); |
As primeiras linhas servem para configurar o webserver Express para o backend. Depois, criamos apenas uma rota POST na raiz do servidor para receber no corpo da requisição o gameId e o objeto com os dados do newGame. Essas duas informações são passadas para a função setGame do contract, apenas adicionando o timestamp atual (dividido por 1000 pois na blockchain timestamps são em segundos). Por fim, esperamos a conclusão da transação e devolvemos o recibo no corpo da resposta HTTP.
Não farei uma interface aqui, então para testar você pode usar o Postman, como ensinado no vídeo abaixo.
Na sequência, vamos criar o consumidor do nosso oráculo.
#3 – Consumer Contract
Nosso oráculo é público, ou seja, não tem qualquer controle de acesso, então seu consumo é bem simples e direto, feito através de um outro smart contract qualquer que sabe o endereço do oráculo. Vou criar nosso OracleConsumer.sol no Remix também e antes de fazer o consumo do oráculo em si, vamos posicionar em nosso arquivo a struct Game (para que esse tipo de dado seja reconhecido por nosso consumer) e também a interface IStorageOracle, que irá fazer com que nosso consumer conheça a função getGame que ele precisará chamar depois.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// SPDX-License-Identifier: MIT pragma solidity ^0.8.26; struct Game { uint timestamp; string team1; uint8 score1; string team2; uint8 score2; uint8 winner; } interface IStorageOracle { function getGame(string calldata gameId) external view returns (Game memory); } |
Opcionalmente você poderia criar uma biblioteca Solidity para compartilhar a struct entre os dois contratos e também um IStorageOracle.sol apenas para a interface, mas por simplicidade optei por colocar desta forma, tudo no mesmo arquivo. Mais tarde, no mesmo arquivo OracleConsumer.sol, vamos definir o contrato de consumer em si.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
contract OracleConsumer { address immutable owner; address public oracleAddress; constructor(){ owner = msg.sender; } function setOracleAddress(address newAddress) public { require(msg.sender == owner, "Unauthorized"); oracleAddress = newAddress; } //gameId = "TEAM1XTEAM2YYYYMMDDHHMM" function getGame(string calldata gameId) external view returns (Game memory) { return IStorageOracle(oracleAddress).getGame(gameId); } } |
Como nosso oráculo é do tipo storage, ou seja, suas informações estão registradas no próprio contrato a qualquer momento, o consumer pode acessá-las diretamente chamando a função getGame que deixamos no oráculo para essa finalidade, apenas montando o gameId de maneira determinística conforme convencionamos anteriormente e chamando a função getGame a partir do storage oracle configurado a partir da interface e do endereço do mesmo na blockchain.
Adicionei ainda um controle de acesso para troca posterior de endereço do oráculo em caso de atualização do oráculo que resulte em novo endereço.
Note que olhando apenas esse consumer desse jeito ele parece bem inútil, certo? Já que poderíamos acessar diretamente o oracle para pegar esta informação. Isso porque esse consumer tem apenas a lógica de consumo da informação mas em uma situação real o contrato seria bem mais complexo do que isso e pegar essa informação do game seria apenas um passo dentre vários para chegar no objetivo final. Por exemplo, ao invés de uma função getGame, o consumer teria um processGame que, baseado nos dados que coletasse, premiasse os vencedores de uma aposta, por exemplo.
Para testar este consumer você não precisa fazer deploy dele, basta estar com seu Remix apontado para uma carteira MetaMask que por sua vez esteja configurada para a mesma rede onde o deploy do nosso oráculo foi realizado. Assim, compile o consumer e teste ele definindo o endereço de deploy do oracle e depois chamando a getGame com um jogo que já tenha sido finalizado e registrado os dados no oracle (via backend).
Com isso finalizamos mais este tutorial. Espero em outra oportunidade poder trazer um exemplo de implementação de oráculo baseado em request-response.
Até lá!
Olá, tudo bem?
O que você achou deste conteúdo? Conte nos comentários.