DEV Community

Cover image for Building Lootboxes with Verifiable Randomness on Polkadot Parachains
Tony Riemer
Tony Riemer

Posted on

Building Lootboxes with Verifiable Randomness on Polkadot Parachains

Key Takeaways:

  • The Ideal Network is Polkadot’s randomness daemon for ink! smart contracts and parachain runtimes.

  • In this guide, we will implement a randomized lootbox in an ink! smart contract with the IDN!

Introduction

The Ideal Network enable verifiable randomness as a service (VRaaS) for the Polkadot ecosystem, like /dev/random for blockchains. Any parachain runtime or ink! smart contract (v5+) can now access cryptographically secure randomness through a simple subscription model!

The IDN brings unpredictable, verifiable randomness as a service for parachain runtimes and smart contracts:

  • On-demand Randomness - All outputs are unpredictable until revealed, unlike consensus-driven VRFs
  • Subscription-based Pricing - Predictable costs under your control, not per-call fees.
  • Blockchain-Native - No manual oracle interaction is needed for parachains, just create a subscription and receive randomness.
  • Multiple Integration Paths - The solution can be integrated with smart contracts or directly into a runtime. In addition, contracts deployed on the IDN receive randomness for free.

The full documentation and more integration guides can be found at https://docs.idealabs.network.

Just show me the code!

→ The full code for this contract is available on github: https://github.com/ideal-lab5/contracts/blob/main/examples/vraas/lootbox/lib.rs

→ The IDN-SDK code is available at https://github.com/ideal-lab5/idn-sdk

Building Lootboxes with VRaaS

This guide demonstrates how to build a basic lootbox with ink! smart contracts using verifiable randomness as a service from the Ideal Network. Our lootbox will be quite simple, with the flow being [register → receive randomness → get random reward].

Setup & Prerequisites

  1. Install and Configure Rust

To get started, we need to install rust and configure the toolchain to default to the latest stable version by running:

curl --proto=https’ --tlsv1.2 -sSf https://sh.rustup.rs | sh 
rustup default stable 
rustup update 
rustup target add wasm32-unknown-unknown 
rustup component add rust-src
Enter fullscreen mode Exit fullscreen mode
  1. Install Cargo-Contract

The cargo contract tool helps you to setup and manage wasm smart contracts written with ink! To get started, simply run

cargo install --force --locked cargo-contract
Enter fullscreen mode Exit fullscreen mode

→ Note: in case of issues, the full installation guide is here.

  1. Setup the Execution Environment

You can deploy the resulting contract on any chain that has an open hrmp channel with the Ideal Network. For easy testing purposes, you can checkout the IDN from github and run it on zombienet, granting you a local version of the IDN with RPC port 9933 and an example Consumer chain on port 9944. To do so:

Checkout the repo: git clone https://github.com/ideal-lab5/idn-sdk

Create a release build from the root: cargo build —release

3'. Install zombienet

Navigate to the e2e dir, follow the guide to setup zombienet
https://github.com/paritytech/zombienet, and finally run:

cd e2e/
zombienet setup polkadot -y
export PATH=/home/driemworks/ideal/idn-sdk/e2e:$PATH
zombienet spawn -p native zombienet.toml 
Enter fullscreen mode Exit fullscreen mode

⚠️ Once both the IDN and IDNC (test chain for deploying contracts, receiving subscriptions) are ready, you must open an HRMP channel between them. We have provided a convenient script to handle this:

cd e2e/scripts/setup
chmod +x open_hrmp.sh
./open_hrmp.sh
Enter fullscreen mode Exit fullscreen mode

Failing to open an HRMP channel will guarantee that all calls to the IDN will fail.

Create the Contract

First we will create a new contract and install the idn-contracts library

cargo contract new lootbox
cd lootbox
Enter fullscreen mode Exit fullscreen mode

Now, we need to include both idn-contracts and parity-scale-codec to the Cargo.toml. Make sure that default-features = false you add the dependency to the std features array in your Cargo.toml:

[dependencies]
codec = { package = “parity-scale-codec”, version = 3.7.4, default-features = false, features = [
    "derive",
] }
idn-contracts = { version = 0.1.0, default-features = false }
# other deps here

...

[features]
default = [”std”]
std = [
    "codec/std",
    “idn-contracts/std”, 
     # other deps here
]
Enter fullscreen mode Exit fullscreen mode

Now we’re ready to get building our lootbox!

VRaaS Setup & Integration

First, we need to configure the idn-client in the contract. This is the primary mechanism through which you can create and manage subscriptions, as well handling logic for when randomness is received. To configure it, you need to configure a few parameters. On Paseo, you can use our test network to deploy contracts for testing out VRaaS (explorer: https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Fidnc0-testnet.idealabs.network#/explorer).

Parameter Value
The IDN Parachain ID (on Paseo) 4502
The IDN Manager Pallet Index in the IDN (on Paseo) 40
Your parachain ID (e.g. IDNC on Paseo) 4594
Contracts Pallet index on your chain (e.g. IDNC on Paseo) 16
Contracts callback call index on your chain (e.g. IDNC on Paseo) 6
Maximum XCM fees e.g. 1_000_000_000 (1 token)
#![cfg_attr(not(feature = std), no_std, no_main)]

#[ink::contract]
mod lootbox {

    use idn_contracts::prelude::*;

    #[ink(storage)]
    pub struct Lootbox {
        idn_client: IdnClient,
        subscription_id: Option<SubscriptionId>,
    }

    impl Lootbox {

        #[ink(constructor)]
        pub fn new() -> Self {
            Self {
                idn_client: IdnClient::new(
                    4502, 40, 4594, 16, 6, 1_000_000_000
                ),
                subscription_id: None,
            }
        }

        #[ink(message)]
        pub fn create_subscription(&mut self) {
            self.subscription_id = Some(
                self.idn_client
                    .create_subscription(100, 10, None, None, None, None)
                    .unwrap(),
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

For a sanity check, run cargo contract build —release to compile the contract, resolve any issues now.

Note: by default, subscriptions are made with 100 ‘credits’ with randomness delivered every 10 blocks. You can change this by specifying the first two parameters passed to the create_subscription call.

→ See our price simulator for a full breakdown of subscription pricing.

⚠️ There MUST be an open HRMP channel between the target parachain and the Ideal Network: https://substrate.stackexchange.com/questions/5445/how-to-open-hrmp-channels-between-parachains.

Adding Lootbox Mechanics

Now we’re ready to modify the contract to introduce our lootbox mechanics!

User Registration Mechanics

For our lootbox, we need to let users register to open the lootbox when we receive a pulse of randomness.

Update imports, the storage struct, and lets introduce a new event type:

use idn_contracts::prelude::*;
use ink::prelude::vec::Vec;
use ink::storage::Mapping;

#[ink(storage)]
pub struct Lootbox {
    idn_client: IdnClient,
    subscription_id: Option<SubscriptionId>,
    // track registered users for the next lootbox
    registered_users: Vec<AccountId>,
    // track if a user is already registered (to prevent duplicates)
    is_registered: Mapping<AccountId, bool>,
}

#[ink(event)]
pub struct UserRegistered {
    #[ink(topic)]
    user: AccountId,
    total_registered: u32,
}
Enter fullscreen mode Exit fullscreen mode

Update the constructor:

#[ink(constructor)]
pub fn new() -> Self {
    Self {
       idn_client: IdnClient::new(4502, 40, 4594, 16, 6, 1_000_000_000),
       subscription_id: None,
       registered_users: Vec::new(),
       is_registered: Mapping::new(),
   }
}
Enter fullscreen mode Exit fullscreen mode

Remove the default flipper functions and replace with registration functionality:

/// Register to get a reward with the next dispatch
#[ink(message)]
pub fn register(&mut self) {
    let caller = self.env().caller();

    // Check if user is already registered
    if self.is_registered.get(caller).unwrap_or(false) {
        panic!(Already registered for this lootbox);
    }

    // Add user to registered list
    self.registered_users.push(caller);
    self.is_registered.insert(caller, &true);

    // Emit event
    self.env().emit_event(UserRegistered {
        user: caller,
        total_registered: u32::try_from(self.registered_users.len()).unwrap(),
    });
}

/// Get the number of registered users
#[ink(message)]
pub fn get_registered_count(&self) -> u32 {
    u32::try_from(self.registered_users.len()).unwrap()
}

/// Check if caller is registered
#[ink(message)]
pub fn is_user_registered(&self) -> bool {
    let caller = self.env().caller();
    self.is_registered.get(caller).unwrap_or(false)
}

/// Get all registered users (for testing/admin purposes)
#[ink(message)]
pub fn get_registered_users(&self) -> Vec<AccountId> {
    self.registered_users.clone()
}
Enter fullscreen mode Exit fullscreen mode

Now, let’s build the lootbox

Define the lootbox rewards schema (put it above the lootbox struct)

#[derive(Debug, PartialEq, Eq, Clone)]
#[ink::scale_derive(Encode, Decode, TypeInfo)]
pub enum Reward {
    Bronze,  // 50% chance
    Silver,  // 30% chance
    Gold,    // 15% chance
    Diamond, // 5% chance
}

/// track user’s reward counts
#[derive(Debug, Default, PartialEq, Eq, Clone)]
#[ink::scale_derive(Encode, Decode, TypeInfo)]
#[cfg_attr(feature = std, derive(ink::storage::traits::StorageLayout))]
pub struct RewardStats {
    bronze: u32,
    silver: u32,
    gold: u32,
    diamond: u32,
}
Enter fullscreen mode Exit fullscreen mode

Add a new mapping to track user rewards, and update the constructor, and introduce new getters.

#[ink(storage)]
pub struct Lootbox {
    idn_client: IdnClient,
    subscription_id: Option<SubscriptionId>,
    // Track registered users for the next lootbox
    registered_users: Vec<AccountId>,
    // Track if a user is already registered (to prevent duplicates)
    is_registered: Mapping<AccountId, bool>,
    // Track rewards received by users
    user_rewards: Mapping<AccountId, RewardStats>,
}

// ink events defined here

impl Lootbox {

        #[ink(constructor)]
        pub fn new() -> Self {
            Self {
                idn_client: IdnClient::new(
                    4502, 40, 4594, 16, 6, 1_000_000_000),
                subscription_id: None,
                registered_users: Vec::new(),
                is_registered: Mapping::new(),
                user_rewards: Mapping::new(),
            }
        }

        /// Get reward stats for a specific user
        #[ink(message)]
        pub fn get_user_rewards(&self, user: AccountId) -> RewardStats {
            self.user_rewards.get(user).unwrap_or_default()
        }

        /// Get reward stats for caller
        #[ink(message)]
        pub fn get_my_rewards(&self) -> RewardStats {
            let caller = self.env().caller();
            self.user_rewards.get(caller).unwrap_or_default()
        }

}
Enter fullscreen mode Exit fullscreen mode

Add new events below the UserRegistered event

   #[ink(event)]
    pub struct LootboxOpened {
        total_users: u32,
    }

    #[ink(event)]
    pub struct RewardGranted {
        #[ink(topic)]
        user: AccountId,
        reward: Reward,
    }
Enter fullscreen mode Exit fullscreen mode

And then implement the core lootbox logic:

/// Process lootbox with random bytes (would be called by VRaaS callback)
#[ink(message)]
pub fn open_lootbox(&mut self, random_bytes: [u8; 32]) {
    let user_count = self.registered_users.len();
    assert!(user_count > 0,No users registered);

    self.env().emit_event(LootboxOpened {
        total_users: u32::try_from(user_count).unwrap(),
    });

    // Distribute rewards to each registered user
    for (index, user) in self.registered_users.iter().enumerate() {
        // Use different bytes for each user to get unique randomness
        let user_random = self.get_user_random(&random_bytes, index);
        let reward = self.determine_reward(user_random);

        // Update reward stats
        let mut stats = self.user_rewards.get(user).unwrap_or_default();
        match reward {
            Reward::Bronze => stats.bronze.saturating_add(1),
            Reward::Silver => stats.silver.saturating_add(1),
            Reward::Gold => stats.gold.saturating_add(1),
            Reward::Diamond => stats.diamond.saturating_add(1),
        };

        self.user_rewards.insert(user, &stats);

        // Emit event
        self.env().emit_event(RewardGranted {
            user: *user,
            reward,
        });
    }

    // Clear registration for next lootbox
    self.clear_registrations();
}

/// Determine reward based on random value
fn determine_reward(&self, random_value: u8) -> Reward {
    // Convert to 0-100 scale
    let roll =
        (u16::from(random_value).saturating_mul(100)).saturating_div(255);

    match roll {
        0..=49 => Reward::Bronze,    // 50%
        50..=79 => Reward::Silver,   // 30%
        80..=94 => Reward::Gold,     // 15%
        95..=100 => Reward::Diamond, // 5%
        _ => Reward::Bronze,
    }
}

/// Get unique random byte for each user
fn get_user_random(&self, random_bytes: &[u8; 32], user_index: usize) -> u8 {
    // Combine multiple bytes for better distribution
    let idx = user_index % 32;
    let next_idx = (user_index.saturating_add(1)) % 32;

    // XOR two bytes for more randomness
    random_bytes[idx] ^ random_bytes[next_idx]
}

Almost There! Implement the randomness receiver to power the lootbox

Paste this beneath the impl Lootbox {} block:

 impl IdnConsumer for Lootbox {
    #[ink(message)]
    fn consume_pulse(
        &mut self,
        pulse: Pulse,
        subscription_id: SubscriptionId,
    ) -> Result<(), Error> {
        let randomness = pulse.rand();
        self.open_lootbox(randomness);
        Ok(())
    }

    // Handle subscription quotes (optional)
    #[ink(message)]
    fn consume_quote(&mut self, quote: Quote) -> Result<(), Error> {
        Ok(())
    }

    // Handle subscription information responses (optional)
    #[ink(message)]
    fn consume_sub_info(&mut self, sub_info: SubInfoResponse) -> Result<(), Error> {
        Ok(())
    }
}
Enter fullscreen mode Exit fullscreen mode

And that’s it! Now we just need build and deploy the contract, create the subscription, and we are good to go!

Build and Deploy

  1. Build the contract with cargo contract build —release.

  2. Navigate to your chain explorer (e.g. polkadotjs) and upload the contract.
    a. If you are using the IDNC example chain described above, navigate to: https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Fidnc0-testnet.idealabs.network#/contracts.
    b. Then click ‘upload + deploy code’ and select the lootbox.contract file

upload_deploy

⚠️ Important!!! Once the contract is deployed, you must fund the contract on BOTH the target chain and the IDN! Without this, xcm fees cannot be accounted for properly. To do this, simply copy the contract address and send it some tokens.

  1. Create a subscription by calling the create_subscription function

create_sub

Wait a ~3-4 blocks for the XCM to reach the IDN and create a subscription.

  1. Register for the lootbox

register

  1. And finally, wait until randomness is received and then query to get your rewards!

If you are not receiving pulses, it’s likely that your contract on the IDN is underfunded.

  1. Query the contract to view your rewards!

get_rewards

Troubleshooting

Create subscription is failing!

→ Ensure you have opened the hrmp channel (see above). Failing to open an HRMP channel will guarantee that all calls to the IDN will fail.

My subscription was created but I’m not receiving anything!

→ Make sure you have funded the contract address on BOTH chains (IDN + IDNC or whichever you are using).

→ Ensure you maximum xcm fees are high enough in your contract.

→ Double check that all parameters are correct when configuring the IDN client in your contract.

Conclusion

If you found this useful, give us some love!

Top comments (0)