WEB3DEV

Cover image for Contrato de Loteria NFT utilizando o ChainLink VRF
Panegali
Panegali

Posted on

Contrato de Loteria NFT utilizando o ChainLink VRF

Introdução

Existem muitas variações de sistemas de loterias ou sorteios construídos na Ethereum. É um jogo de azar emocionante que pode pagar grandes quantias de dinheiro para aqueles que ganham, por isso é muito proeminente no espaço cripto. Ao projetar e implementar tais sistemas, há muitas considerações de segurança e custo. Neste artigo, vamos apresentar nossa abordagem para o clássico jogo de loteria.

Considerações de Design

Existem três princípios básicos que consideramos ao projetar este contrato:

Custo

A loteria deve ser eficiente em termos de gás a fim de reduzir a carga dos custos de gás para o usuário final. Adotamos uma abordagem ingênua para tornar o processo de compra e resgate de bilhetes o mais eficiente possível para os usuários.

Verdadeira Aleatoriedade

Para obter aleatoriedade verdadeira e verificável, utilizamos o ChainLink VRF em nosso contrato para garantir a autenticidade dos resultados do jogo. O ChainLink VRF é um dos oráculos de números aleatórios mais amplamente utilizados na Ethereum, por isso atende perfeitamente ao nosso propósito.

Interoperabilidade

Queríamos que nosso sistema de loteria pudesse ser facilmente integrado aos ecossistemas DeFi existentes. Portanto, escolhemos que a moeda da loteria seja um token ERC20.

Por que VRF?

Um usuário final pode achar que uma solução como o ChainLink VRF adiciona complexidade e sobrecarga extra ao contrato. No entanto, é essencial que o VRF seja usado para garantir que os usuários joguem um jogo justo.

Abordagem Pseudo-aleatório

Em teoria, é possível gerar números pseudo-aleatórios na Ethereum. Por que é chamado de pseudo-aleatório? Porque, apesar de parecer aleatório, o número gerado na cadeia pode ser influenciado pelos validadores que processam o bloco. Aqui está um exemplo de um gerador de números pseudo-aleatórios em Solidity:

function random() internal view returns (uint256) {
   return uint256(keccak256(abi.encodePacked(
     tx.origin,
     blockhash(block.number - 1),
     block.timestamp
   )));
 }
Enter fullscreen mode Exit fullscreen mode

Como você pode ver, este gerador usa dados conhecidos no momento da validação do bloco. Isso pode permitir que os validadores influenciem o resultado do gerador escolhendo excluir/incluir sua transação em um bloco específico.

Crédito da imagem: ChainLink docs

Detalhamento do Código

Boilerplate

Para gerenciar o estado da loteria, inicializamos uma instância de LotteryInstance e uma struct Ticket, além de mapeamentos para armazená-las.

struct LotteryInstance {
   uint startTime;
   uint prizePool;
   uint claimedAmount;
   uint seed;
   uint8[(5)] winningNumbers;
   LotteryStatus status;
}
struct Ticket {
   uint lottoId;
   uint8[(5)] numbers;
   bool claimed;
}
enum LotteryStatus {
   IN_PLAY,
   VRF_REQUESTED,
   SETTLED       
}
mapping(uint => LotteryInstance) lottoIdToLotto;
mapping(uint => uint) requestIdToLottoId;
mapping(uint => Ticket) ticketIdToTicket;
mapping(uint => mapping(uint => mapping(uint => uint))) lottoIdToPositionToNumberToCounter;
uint public ticketPrice;
uint public currentLottoId;
uint public expiry;
uint currentTicketId;
IERC20 CALLISTO;
Enter fullscreen mode Exit fullscreen mode

A Instância da Loteria

Cada instância da loteria é definida pela struct LotteryInstance e gerenciada pelo mapeamento lottoIdToLotto. O lottoId atual pode ser visualizado a partir de currentLottoId.

struct LotteryInstance {
   uint startTime;
   uint prizePool;
   uint claimedAmount;
   uint seed;
   uint8[(5)] winningNumbers;
   LotteryStatus status;
}
Enter fullscreen mode Exit fullscreen mode

startTime — timestamp (registro de data e hora) do bloco no início da loteria

prizePool — prêmio total

claimedAmount — quantia total reivindicada

seed — valor de retorno do VRF

winningNumbers — array de números vencedores derivados da seed

status — estado atual da loteria

Tickets

Cada bilhete é um token ERC721 cunhado pelo contrato Lottery. É definido pela struct Ticket. Os ingressos são gerenciados pelo mapeamento ticketIdToTicket.


struct Ticket {
   uint lottoId;
   uint8[(5)] numbers;
   bool claimed;
}
Enter fullscreen mode Exit fullscreen mode

lottoId — loteria à qual o ingresso pertence

numbers — números escolhidos pelo usuário

claimed — se o ingresso foi reivindicado ou não

Variáveis e Constantes


uint public ticketPrice;
uint public currentLottoId;
uint public expiry;
uint currentTicketId;

Enter fullscreen mode Exit fullscreen mode

ticketPrice — preço por ingresso

currentLottoId — loteria atual em execução

expiry — duração de uma única instância da loteria

currentTicketId — TokenId dos NFTs

Inicializando o VRF

Escolhemos o modelo de assinatura VRF v2 da ChainLink. Isso requer a criação antecipada de uma assinatura financiada pelo implantador. Você pode obter mais informações em vrf.chain.link.

Para inicializar o VRF no contrato, herdamos de VRFConsumerBaseV2.sol.


import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";

import "@chainlink/contracts/src/v0.8/vrf/VRFConsumerBaseV2.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract CallistoLotto is VRFConsumerBaseV2, ERC721 {
...

Enter fullscreen mode Exit fullscreen mode

Em seguida, é inicializado no construtor com os seguintes parâmetros:


VRFCoordinatorV2Interface COORDINATOR;
uint64 s_subscriptionId;
bytes32 keyHash =
   0x474e34a077df58807dbe9c96d3c009b23b3c6d0cce433e59bbf5b34f823bc56c;
uint32 callbackGasLimit = 100000;
uint16 requestConfirmations = 3;
uint32 numWords = 1;
/**
* HARDCODED FOR SEPOLIA
* COORDINATOR: 0x8103B0A8A00be2DDC778e6e7eaa21791Cd364625
**/

constructor(
   uint64 subscriptionId,
   address coordinator,
   address callistoToken,
   uint _expiry,
   uint _ticketPrice
)
   ERC721("Callisto Lottery Ticket", "CTKT")
   VRFConsumerBaseV2(coordinator) // 0x8103B0A8A00be2DDC778e6e7eaa21791Cd364625
{
   COORDINATOR = VRFCoordinatorV2Interface(
       coordinator // 0x8103B0A8A00be2DDC778e6e7eaa21791Cd364625
   );
   s_subscriptionId = subscriptionId;
   ...
Enter fullscreen mode Exit fullscreen mode

Ciclo de Vida da Loteria

Cada loteria tem 3 fases em seu ciclo de vida. É definido pelo seguinte enum:


enum LotteryStatus {
   IN_PLAY,
   VRF_REQUESTED,
   SETTLED      
}
Enter fullscreen mode Exit fullscreen mode

IN_PLAY significa que a loteria está atualmente em andamento. Os bilhetes podem ser comprados nesta fase.

VRF_REQUESTED indica que uma solicitação VRF foi feita ao coordenador da ChainLink. Bilhetes não podem ser comprados ou reivindicados durante esta fase.

SETTLED é a fase final de um jogo, que revela os números vencedores com base na saída VRF. Os bilhetes podem ser reivindicados durante esta fase.

Principais Funções da Loteria

O ciclo completo da loteria é operado por 3 funções principais. Estas são startNextLotto, endLotto e drawLottoResult. Vamos analisar como cada uma delas funciona.


function startNextLotto()
   public
{
   require(
       newLottoStartable(),
       "Lottery: Either lotto is in play or VRF has been requested"
   );
   currentLottoId++;
   lottoIdToLotto[currentLottoId] = LotteryInstance(
       block.timestamp,
       0,
       0,
       0,
       [0,0,0,0,0],
       LotteryStatus.IN_PLAY
   );
}

...

function newLottoStartable()
   internal
   view
   returns(bool)
{
   return(
       block.timestamp >= lottoIdToLotto[currentLottoId].startTime + expiry
       &&
       lottoIdToLotto[currentLottoId].status == LotteryStatus.SETTLED
   );
}
Enter fullscreen mode Exit fullscreen mode

A função startNextLotto, quando chamada nas condições corretas (conforme descrito em newLottoStartable), inicializará uma nova LotteryInstance.


function endLotto()
   public
   returns(uint requestId)
{
   require(
       currentLottoEndable(),
       "Lottery: Either lotto is in play or VRF has been requested"
   );
   lottoIdToLotto[currentLottoId].status = LotteryStatus.VRF_REQUESTED;
   uint _requestId = COORDINATOR.requestRandomWords(
       keyHash,
       s_subscriptionId,
       requestConfirmations,
       callbackGasLimit,
       numWords
   );
   requestIdToLottoId[_requestId] = currentLottoId;
   return(_requestId);
}

...

function currentLottoEndable()
   internal
   view
   returns(bool)
{

   return(
       block.timestamp >= lottoIdToLotto[currentLottoId].startTime + expiry
       &&
       lottoIdToLotto[currentLottoId].status == LotteryStatus.IN_PLAY
   );
}
Enter fullscreen mode Exit fullscreen mode

_endLotto_ encerra a instância atual da loteria quando as condições são atendidas e faz uma solicitação VRF ao coordenador ChainLink.


function drawLottoResult(uint seed)
   internal
{
   lottoIdToLotto[currentLottoId].seed = seed;
   lottoIdToLotto[currentLottoId].status = LotteryStatus.SETTLED;
   lottoIdToLotto[currentLottoId].winningNumbers = drawWinningNumbers(seed);
}

...

function fulfillRandomWords(
   uint,
   uint256[] memory _randomWords
) internal override {
   drawLottoResult(_randomWords[0]);
}

...

function drawWinningNumbers(uint seed)
   internal
   pure
   returns(uint8[(5)] memory nums)
{
   for(uint i=0;i<5;i++) {
       seed = uint(keccak256(abi.encode(seed)));
       nums[i] = uint8(seed%10);
   }
   return(nums);
}
Enter fullscreen mode Exit fullscreen mode

A função _drawLottoResult_ é a função de retorno de chamada usada pelo ChainLink VRF para liquidar a instância atual da loteria. Ela define o status, a semente (seed) e os números vencedores, conforme mostrado acima. Isso é feito por meio da função _fulfillRandomWords_ exigida pelo contrato _VRFConsumerBase_. Os números vencedores são sorteados usando a função simples drawWinningNumbers usando a semente uint256 fornecida pela ChainLink. A _LotteryInstance_ para a Loteria Id respectiva é atualizada com as informações.

Lógica de Compra de Bilhetes


function buyTicket(uint8[(5)] memory numbers)
   public
{
   require(
       isValidNumbers(numbers),
       "Lottery: Invalid set of numbers!"
   );
   require(
       canBuyTickets(),
       "Lottery: Either lotto has been settled, VRF has been requested or is awaiting closure"
   );
   CALLISTO.transferFrom(msg.sender, address(this), ticketPrice);
   currentTicketId++;
   _safeMint(msg.sender, currentTicketId);
   ticketIdToTicket[currentTicketId] = Ticket(
       currentLottoId,
       numbers,
       false
   );
   lottoIdToLotto[currentLottoId].prizePool += ticketPrice;
   for(uint i=0; i<5; i++) {
       incrementNumberPos(i, numbers[i]);
   }
}

...

function canBuyTickets()
   internal
   view
   returns(bool)
{
   return(
       block.timestamp <= lottoIdToLotto[currentLottoId].startTime + expiry
       &&
       lottoIdToLotto[currentLottoId].status != LotteryStatus.VRF_REQUESTED
       &&
       lottoIdToLotto[currentLottoId].status != LotteryStatus.SETTLED
   );
}

...

function isValidNumbers(uint8[(5)] memory numbers)
   internal
   pure
   returns(bool)
{
   for(uint i=0; i<5; i++) {
       if(numbers[i] > 9) {
           return(false);
       }
   }
   return(true);
}

...

function incrementNumberPos(uint pos, uint num)
   internal
{
   lottoIdToPositionToNumberToCounter[currentLottoId][pos][num]++;
}
Enter fullscreen mode Exit fullscreen mode

A função buyTicket recebe a escolha do usuário de números como argumento. A entrada deve ser um array de 5 números, cada um variando de 0 a 9. A validade da entrada do usuário é verificada pela função isValidNumbers. Uma vez que um bilhete é comprado, a quantidade necessária de tokens ERC20 (ticketPrice) é deduzida do saldo do usuário e um NFT de bilhete é emitido contendo informações sobre os números escolhidos pelo usuário e a loteria atual.

Lógica de Reivindicação de Bilhete

Cada loteria ou sorteio possui um modelo de distribuição de prêmios diferente. O PowerBall requer que você acerte cinco números 'bola branca' entre 1-69 e um número 'powerball' entre 1-26. Nosso modelo difere, de modo que, para ganhar uma parte do prêmio, você precisa acertar o número exato nas posições exatas. No entanto, as vitórias não precisam estar na mesma ordem. Aqui está um exemplo:

Considere os bilhetes #1: 92748, #2: 01837 e #3: 17473. Considere o número vencedor como 12749.

Neste caso, o bilhete 1 tem três acertos (2, 7, 4 nas posições exatas do número vencedor), o bilhete 2 não tem acertos e o bilhete 3 tem 1 acerto (o primeiro dígito corresponde à posição).

O prêmio por acerto é determinado pela parcela do acerto (se não houver comissão/margem da casa, isso seria 20%) dividido pelo número total de pessoas que acertaram a mesma posição que você. Se você tiver vários acertos, seu prêmio é a soma dos prêmios ganhos em cada acerto.

Aqui está como a função claimTicket funciona:

function claimTicket(uint ticketId)
   public
{
   require(
       ownerOf(ticketId) == msg.sender,
       "Lottery: You don't own this ticket!"
   );
   require(
       lottoIdToLotto[ticketIdToTicket[ticketId].lottoId].status == LotteryStatus.SETTLED,
       "Lottery: The requested lottery instance is not settled"
   );
   require(
       !ticketIdToTicket[ticketId].claimed,
       "Lottery: Nice try, you've already claimed this ticket"
   );
   ticketIdToTicket[ticketId].claimed = true;
   uint8[(5)] memory numbers = getTicketNumbers(ticketId);
   uint prizePoolShare = getPrizePoolShare(ticketIdToTicket[ticketId].lottoId, numbers);
   lottoIdToLotto[ticketIdToTicket[ticketId].lottoId].prizePool -= prizePoolShare;
   CALLISTO.transfer(msg.sender, prizePoolShare);


}

...

function getPrizePoolShare(uint lottoId, uint8[(5)] memory numbers)
   internal
   view
   returns(uint share)
{
   for(uint i=0; i<5; i++) {
       if(numbers[i] == lottoIdToLotto[lottoId].winningNumbers[i]) {
           uint nc = getNumberCount(lottoId, i, numbers[i]);
           share += (lottoIdToLotto[lottoId].prizePool) / (5 * nc);
       }
   }
   return(share);
}

...

function getNumberCount(uint lottoId, uint pos, uint num)
   internal
   view
   returns(uint)
{
   return(lottoIdToPositionToNumberToCounter[lottoId][pos][num]);
}
function drawWinningNumbers(uint seed)
   internal
   pure
   returns(uint8[(5)] memory nums)
{
   for(uint i=0;i<5;i++) {
       seed = uint(keccak256(abi.encode(seed)));
       nums[i] = uint8(seed%10);
   }
   return(nums);
}
Enter fullscreen mode Exit fullscreen mode

Primeiro, verificamos se o usuário é o proprietário do bilhete em questão e se ele já foi reivindicado. Em seguida, verificamos se a instância da loteria à qual o bilhete pertence foi liquidada. Se sim, obtemos o prêmio usando o método conforme descrito anteriormente, usando a função getPrizePoolShare.

Se você se lembra do código modelo no início, terá notado um mapeamento aninhado da seguinte forma:

mapping(uint => mapping(uint => mapping(uint => uint))) lottoIdToPositionToNumberToCounter;

Enter fullscreen mode Exit fullscreen mode

Esse mapeamento é usado para armazenar a contagem de cada número em cada posição para calcular o prêmio. No exemplo acima, criamos uma função auxiliar para acessá-lo (getNumberCount). Esse mapeamento é atualizado na função buyTicket usando incrementNumberPos.

Conclusão

Aqui termina este artigo. Esperamos que tenha gostado de ler sobre nossa loteria tanto quanto gostamos de projetá-la. Tal abordagem para um sorteio descentralizado pode ser viável para certos ecossistemas DeFi. Claro, existem centenas de maneiras de criar um jogo divertido e viciante para os usuários, e esta não é a única maneira. Nosso contrato foi inspirado no contrato de Loteria da PancakeSwap, que tem um conjunto de regras diferente do nosso. Achamos interessante o uso de NFTs feito por eles e decidimos fazer nossa própria versão com um toque especial.

Se você achou isso útil, siga-nos para acompanhar nossas últimas descobertas em DeFi.

Código -> https://github.com/callisto-eth/callisto-lottery/tree/master


Escrito por Callisto Labs

Callisto Labs -> https://github.com/callisto-eth

0xKits -> https://github.com/0xKits

Obsidian -> https://github.com/redPanda69

gzfs -> https://github.com/gzfs


Artigo escrito por Callisto Labs. Traduzido por Marcelo Panegali.

Latest comments (0)