P2P Semi-Autonomous Games on the ETF Network: Timelock Commitments and Bit Roulette [Part 2]
Learn how to build a game of bit roulette with timelock commitments on the ETF network
This is the second installment in an ongoing series to research and analyze the realization of p2p, semi-autonomous games on the ETF network. You should read Part 1 here before reading through this 2nd part.
Overview
In part 1, we discussed the capabilities of the ETF network as it applies to blockchain-based, p2p gaming. Specifically, the ETF network can act as a logical clock for asynchronous, decentralized, p2p games, providing the groundwork for semi-autonomous worlds and unlocking new game mechanics for web3 game experiences. Now that we have described how this could work in theory in part 1, we now want to show how it works in practice by developing a fully functional game based on this concept. The goal of this article is to provide an example of how to build an decentralized, endless, semi-autonomous, p2p game of bit-roulette.
In this article, we will implement a p2p game of bit-roulette that uses the ETF network as an ‘event source’, where specific blocks in the ETF network correspond to events in the game. The game uses the ETF network’s (ETFN) leaked IBE secret (in the block header corresponding to the game event’s block) to decrypt player moves specified for the event and to calculate the new state. To accomplish this, we introduce a new commitment scheme based on timelock encryption: timelock commitments.
If you find this article interesting or want to learn more information, subscribe to this newsletter to get notified when we release a new publication! In the next edition, we will delve into building a user interface with the etf.js library.
You can also stay up to date with our progress by following us on github or join the conversation on discord.
BitRoulette (on a blockchain)
This is an incredibly simple game mechanic that could serve as the foundation for something much more complex. The goal: guess if a number will be even or odd. If you get it right, you earn a token. The game operates in non-stop and non-overlapping sequential rounds. A dealer defines the secrets (in the set {0, 1}) for each upcoming round before starting the game (of course, this could be an unfair game mechanic, but this is fine for demonstration purposes). They also map a round to a slot in ETF consensus. This is the mechanism that ties the round number to the game event. The block header that will be authored in the given slot reveals the secret number encrypted for the round.
Setup Phase
Once rounds have been setup, the guessing phase begins. Participants guess the parity of the round’s secret bit, even or odd.
Guessing Phase
And finally, at the end of each round, the player guesses are revealed during the advance_clock phase (i.e. reveal). Players who correctly guessed each win a token from “the house” (the contract), while other players gain and lose nothing.
Advance Clock Phase (Reveal and Verify)
Timelock Commitments
Before we go much further, we need to address the elephant in the room. If players are choosing either 0 or 1 and also issuing commitments to their choices, doesn’t that imply it would be really easy to determine if someone played a 0 or a 1? The answer is yes. To account for this, we introduce a new commitment scheme based on timelock encryption: timelock commitments.
First, lets recap how the non-threshold variant of our timelock scheme works. Here, a message m is encrypted for an identity. The corresponding secret key is only made available (by ETF consensus) after some length of time, after which the decryption function can be invoked.
Using this, we can design a timelock commitment scheme, where the commitment itself is only verifiable after a given length of time. Let m be any length binary string (i.e. an element of {0,1}*). Then we can generate a timelocked commitment for that value by calculating the value c as follows:
Once the secret associated with the ID is leaked by the ETF network, the msk and message can be recovered and then used to verify the commitment. We will use this as the basis for our game of roulette. Participants timelock their choice of parity, 0 or 1, and also generate a timelock commitment to that choice.
Let’s Make A Game
Finally, we’re getting to the code! You can see the completed contract on github here. The game logic is not going to be perfect, but should serve as a jumping off point for showing you how to use timelock encryption and timelock commitments to enable trustless multiparty interactions.
For the rest of the article, it will be really helpful if you are familiar with Rust and ink! smart contracts. Also, install the cargo contract tool here. Here are some useful links to get you started:
In the next installment in this series, we will see how to build a user interface to interact with the contract. Using the etf.js library, we can easily use the ETFN’s timelock encryption capabilities in the browser.
Getting started…
To get started we will adapt the template here, which uses the custom ETFEnvironment allowing us to call the ETFN’s chain extension to “check the time”. This feature is technically optional, but it will provide a much richer user experience if used.
Clone the contracts repo and open it in an editor.
git clone https://github.com/ideal-lab5/contracts.git
cd contracts
Copy the `template` and rename it to `roulette-game`.
Open the newly copied lib.rs file and change instances of the name `Template` to `Roulette`. Also, make sure to update the cargo.toml file to your liking (e.g. update package name, author, etc.). With that out of the way, we can get started.
Our game contract needs two major capabilities:
to place a (secret) guess
to correctly advance the game clock
To accomplish this, we take advantage of the idea of timelock commitments presented above. The contract must allow a dealer or owner to specify the event schedule, dictating when game events should happen (in our case, game events refer to the end of the guessing period) and data that should be revealed at the event (a round secret whose parity is being guessed).
First, we need to define a few structs. The TlockMessage is used to hold ciphertexts and commitments.
/// a timelocked message
#[derive(Clone, Debug, scale::Decode, scale::Encode, PartialEq)]
#[cfg_attr(
feature = "std",
derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout)
)]
pub struct TlockMessage {
/// the ciphertext
ciphertext: Vec<u8>,
/// a 12-byte nonce
nonce: Vec<u8>,
/// the ibe ciphertext
capsule: Vec<u8>, // a single ibe ciphertext is expected
/// a timelock commitment for the message
commitment: Vec<u8>,
}
We also must define a game event. Game events have an optional name (e.g. “First Round“, “Match Point“, etc). It also holds a vector of timelocked messages that are revealed along with player moves. In the context of roulette, this data is the timelocked result of ‘spinning the wheel’, so either 0 or 1.
/// represents a new event in the game
#[derive(Clone, Debug, scale::Decode, scale::Encode, PartialEq)]
#[cfg_attr(
feature = "std",
derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout)
)]
pub struct GameEvent {
/// a name to associate with this event
name: Option<[u8;32]>,
/// extra data that can be revealed at this slot
/// as part of an in-game event
data: Vec<TlockMessage>,
}
We also define some types to make our code a little easier to manage:
pub type RoundNumber = u8;
pub type SlotNumber = u64;
And finally, we can define the game’s storage:
#[ink(storage)]
pub struct Roulette {
/// the event schedule maps slot numbers to ciphertexts
/// containing secret, additional data for the round.
/// in the roulette, this data is the outcome of the roulette wheel
/// note that this implies the game is not inherently random or fair
/// and that is not the intention of this example
event_schedule: Mapping<RoundNumber, GameEvent>,
/// a map between rounds (slot ids) and player moves
/// this can be pruned after each successive clock advance
guesses: Mapping<(RoundNumber, AccountId), TlockMessage>,
/// map the round index to the set of winners
winners: Mapping<u8, Vec<AccountId>>,
/// track player winnings
player_balance: Mappaing<AcocuntId, Balance>,
/// the 'house balance' for making payments to winners
balance: Balance,
/// the 'owner' of the casino
dealer: AccountId,
/// the current round number
current_round: u8,
}
The Dealer
In part 1, we briefly discussed static v.s. dynamic event schedules. In the case of bit roulette, we are using a dynamic schedule. With a dynamic schedule, the owner of the game can produce new, ad-hoc events for the game. In our case, the owner must provide the events and the timelocked round secrets revealed.
#[ink(message)]
pub fn set_event_schedule(
&mut self,
events: Vec<(RoundNumber, SlotNumber, GameEvent)>,
) -> Result<(), Error> {
let caller = self.env().caller();
if !caller.eq(&self.dealer) {
return Err(Error::NotOwner);
}
let mut event_schedule = Mapping::new();
events.iter().for_each(|g| {
event_schedule.insert(g.0.clone(), &g.2);
});
self.event_schedule = event_schedule;
self.env().emit_event(EventScheduleSet{});
Ok(())
}
The Guessing Phase
Next, participants need to be able to place a bid. They do so by specifying a TlockMessage to be associated with a given round number.
/// place a guess for a future round of roulette
#[ink(message)]
pub fn guess(
&mut self,
round: RoundNumber,
guess: TlockMessage
) -> Result<(), Error> {
let caller = self.env().caller();
// allow for guesses to be overwritten if desired
// overwrites only available before the round has happened
// so no block exists in the round slot
if let Some(event) = self.event_schedule.get(round) {
if self.env().extension().check_slot(event.slot) {
return Err(Error::RoundCompleted)
}
self.guesses.insert((round, caller), &guess);
}
Ok(())
}
Advancing the Clock
Once the ETF network has authored a block in a slot (associated with a round in the game), the messages can be decrypted offchain and supplied to the advance_clock function. The advance clock function verifies the timelock commitments against the provided data and uses it to determine the winners of the round. The logic in this function could become rather complex, so we kept it as simple as possible for now. Either ALL player moves are provided or the clock cannot advance. It assumes all players issue valid ciphertexts. Obviously, in a real situation this is an unreasonable expectation, but for the sake of a demo it is fine.
/// advance the clock from the current round to the next one
#[ink(message)]
pub fn advance_clock(
&mut self,
round_secret: (u8, [u8;32]),
moves: Vec<(AccountId, u8, [u8;32])>,
) -> Result<(), Error> {
if let Some(game_event) = self.event_schedule.get(self.current_round) {
// ensure clock advancement is legal
if self.env().extension().check_slot(game_event.slot) {
return Err(Error::RoundCompleted)
}
let mut input = Vec::new();
input.push(round_secret.0);
if !verify_tlock_commitment(
input,
round_secret.1,
game_event.data[0].commitment.clone()
) {
return Err(Error::InvalidRoundSecret)
}
// a vec to track any input moves for players that didn't play in the round
let mut bad_moves: Vec<(AccountId, u8, [u8;32])> = Vec::new();
// a vec to track any moves where the calculated hash does not match the expected one
let mut error_moves: Vec<(AccountId, u8, [u8;32])> = Vec::new();
let mut winners: Vec<AccountId> = Vec::new();
// for now, we assume that all moves must be provided at once
let mut number_valid_moves = 0;
moves.iter().for_each(|m| {
// fetch all the plays comitted to for the round
if let Some(guess) = self.guesses.get((self.current_round, m.0)) {
let c = guess.commitment;
let mut input = Vec::new();
input.push(m.1);
if !verify_tlock_commitment(
input,
m.2,
c,
) {
error_moves.push(*m);
} else {
number_valid_moves += 1;
if m.1.eq(&round_secret.0) {
let mut current_winners = self.winners.get(self.current_round).expect("should exist");
current_winners.push(m.0);
self.winners.insert(self.current_round, ¤t_winners);
self.balance -= 1;
let mut new_balance = 1;
if let Some (balance) = self.player_balance.get(m.0) {
new_balance += balance;
} else {
self.player_balance.insert(m.0, &new_balance);
}
}
}
};
});
if number_valid_moves != moves.len() {
return Err(Error::MissingPlayerMoves)
}
self.current_round += 1;
}
Ok(())
}
Next Steps
In the next article, we’ll show how to use the etf.js library to build a user interface to interact with this contract and to use timelock encryption.
We are also working on a delayed transactions framework, which would allow for the reveal phase of the protocol to be removed, enabling a non-interactive protocol between all participants, greatly enhancing the user experience.