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
)));
}
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;
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;
}
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;
}
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;
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 {
...
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;
...
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
}
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
);
}
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
);
}
_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);
}
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]++;
}
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);
}
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;
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)