Skip to main content

Raffle contract

This section presents the Archetype version of a raffle contract, inspired by the version presented for other languages (Ligo, Smartpy). The difference is that it uses the timelock feature to secure the winning ticket picking process.

A raffle is a gambling game, where players buy tickets; a winning ticket is randomly picked and its owner gets the jackpot prize.

The Michelson language does not provide an instruction to generate a random number. We can't use the current date (value of now) as a source of randomness either. Indeed, bakers have some control on this value for the blocks they produce, and could therefore influence the result.

info

The source code of the raffle contract is available in this repository.

Picking the winning ticket

The winning ticket id is obtained as the remainder of the Euclidean division of an arbitrarily large number, called here the raffle key, by the number of ticket buyers, called here players. For example, if the raffle key is 2022, and the number of raffle players is 87, then the winning ticket id is 21 (typically the 21st ticket).

The constraint is that this raffle key must not be known by anyone, nor the players or even the admin. Indeed if someone knows in advance the raffle key, it is then possible to influence the outcome of the game by buying tickets until one of them is the winning one (there is only one ticket per address, but someone can have several addresses). As a consequence:

  • the raffle key cannot be simply stored in the contract.
  • the raffle key cannot be a secret that only the admin knows (for the reason above), and that the admin would pass to the contract when it is time to announce the winner. Indeed, the admin could disappear, and no winner would ever be announced.

For the admin not to be the only one to know the key, each player must possess a part of the key (called here partial key), such that the raffle key is the sum of each player's partial key. For the player's partial key not to be known by the other players, it must be cyphered by the player. When it comes to selecting the winning ticket, the user is required to reveal its key for the contract to compute the raffle key.

However, a player could influence the outcome by not revealing the partial key. It is then necessary that the encrypted partial key can be decrypted by anyone at some time. A reward is sent to the account that reveals a key.

The timelock encryption feature of the Michelson chest data type provides the required property: a timelocked value is encrypted strongly enough that even the most powerful computer will take more than a certain amount of time to crack it, but weakly enough that given a bit more time, any decent computer will manage to crack it. That is to say that, beyond a certain amount of time, the value may be considered public.

Raffle storage

The contract is originated with the following parameters:

  • owner is the address of the contract administrator
  • jackpot is the prize in tez
  • ticket_price is the price in tez of a ticket
archetype raffle(
owner : address,
jackpot : tez,
ticket_price : tez)

State

The contract holds:

  • a state with 3 possible values:
    • Created is the initial state during which tickets cannot be bought yet
    • Initialized is the state when the administrator initializes the raffle
    • Transferred is the state when prize has been transferred to the winner
states =
| Created initial
| Initialized
| Transferred
  • a record of settings, initialized to none:
    • open date beyond which tickets can be bought
    • the date beyond which tickets cannot be bought
    • the time used to generate the timelocked value of the raffle key (it should be high enough to be compliant with the close date)
    • the reveal fee
record r_settings {
open_buy : date;
close_buy : date;
chest_time : nat;
reveal_fee : rational;
}
variable o_settings : option<r_settings> = none

The schema below illustrates the periods defined by these dates, and the contract's states: Timeline of the different states of the contract, from &quot;created&quot; to &quot;transferred&quot; with &quot;initialized&quot; in between

Other

The contract also holds:

  • a collection that will contain the addresses of all players and their raffle key:
asset player {
id : address;
locked_raffle_key : chest; (* partial key *)
revealed : bool = false;
}
  • the raffle key, updated when a player's partial key is revealed:
variable raffle_key  : nat = 0

Entrypoints

initialise

The initialise entrypoint is called by the contract admin (called "owner") to set the main raffle parameters:

  • open buy is the date beyond which players can buy ticket
  • close buy is the date beyond which players cannot buy ticket
  • chest time is the difficulty to break players' partial raffle key encryption
  • reveal fee the percentage of ticket price transferred when revealing a player's raffle key
info

Currently you may count from a chest time of 500 000 per second on a standard computer, to a chest time value of 500 000 000 per second on dedicated hardware.

It requires that:

  • the open and close dates to be consistent
  • the reveal fee to be equal to or less than 1
  • the transferred amount of tez to be equal to the jackpot storage value

It transitions from Created state to Initialized, and sets the raffle parameters.

transition initialise(ob : date, cb : date, t : nat, rf : rational) {
called by owner
require {
r0 : now <= ob < cb otherwise INVALID_OPEN_CLOSE_BUY;
r1 : rf <= 1 otherwise INVALID_REVEAL_FEE;
r2 : transferred = jackpot otherwise INVALID_AMOUNT
}
from Created to Initialised
with effect {
o_settings := some({open_buy = ob; close_buy = cb; chest_time = t; reveal_fee = rf})
}
}

buy

The buy entrypoint may be called by anyone to buy a ticket. The player must transfer the encrypted value of the partial raffle key, so that the partial key value may be potentially publicly known when it comes to declaring the winner ticket.

It requires that:

  • the contract be in Initialized state
  • the transferred amount of tez to be equal to the ticket price
  • the close date not be reached

It records the caller's address in the player collection.

entry buy (lrk : chest) {
state is Initialised
constant {
settings ?is o_settings otherwise SETTINGS_NOT_INITIALIZED;
}
require {
r3 : transferred = ticket_price otherwise INVALID_TICKET_PRICE;
r4 : settings.open_buy < now < settings.close_buy otherwise RAFFLE_CLOSED
}
effect { player.add({ id = caller; locked_raffle_key = lrk }) }
}
info

Note that the add method fails with (Pair "KeyExists" "player") if the caller is already in the collection.

reveal

The reveal entry point may be called by anyone to reveal a player's partial key and contribute to the computation of the raffle key. The caller receives a percentage of the ticket price as a reward.

It requires that:

  • the contract be in Initialized state
  • the date is valid is beyond close_buy
entry reveal(addr : address, k : chest_key) {
state is Initialised
constant {
settings ?is o_settings otherwise SETTINGS_NOT_INITIALIZED;
value_player ?is player[addr] otherwise PLAYER_NOT_FOUND;
}
require {
r5 : settings.close_buy < now otherwise RAFFLE_OPEN;
r6 : not value_player.revealed otherwise PLAYER_ALREADY_REVEALED
}
effect {
match open_chest(k, value_player.locked_raffle_key, settings.chest_time) with
| some (unlocked) -> begin
match unpack<nat>(unlocked) with
| some(partial_key) ->
raffle_key += partial_key;
player[addr].revealed := true
| none -> player.remove(addr)
end
end
| none -> fail(INVALID_CHEST_KEY)
end;
transfer (settings.reveal_fee * ticket_price) to caller;
}
}

Note that the player addr may be removed in 2 situations:

  1. the chest key opens the chest but is unable to decipher the content; this is the case if for example the chest was not generated with the correct chest time value
  2. the chest is deciphered properly, but it does not contain an integer value

Note at last that in all cases, the caller is rewarded for the chest key when it is valid.

transfer

When all players have been revealed, anyone can call the transfer entrypoint to transfer the jackpot to the winning ticket. It transitions to Transferred state:

transition %transfer() {
require {
r7: player.select(the.revealed).count() = player.count() otherwise EXISTS_NOT_REVEALED
}
from Initialised to Transferred
with effect {
const dest ?= player.nth(raffle_key % player.count()) : INTERNAL_ERROR;
transfer balance to dest;
}
}