Hatching NFTs with the WAX RNG Oracle

idmontie

Ivan Montiel

Posted on August 6, 2023

Hatching NFTs with the WAX RNG Oracle

Introduction

We’ve minted ChickEgg NFTs in our previous sections. Next, we’ll take those NFTs and let users “hatch” their egg by transferring it to the Smart Contract. We’ll leverage a randomness provider on the WAX blockchain in order to give accounts randomly picked BabyChick NFTs from their egg.

Hatching an Egg

We have all of templates defined, but so far we have only minted ChickEggs. Let’s add onto our existing contract to let users hatch their egg into a BabyChick!

When a user hatches an egg, we want to randomly mint a BabyChick NFT for them. We’ll mint one of the three templates we defined: Epic, Rare, and Common.

In order to do that, we will need to use the WAX RNG Native Blockchain Service which we will call the wax-orng. The flow for how the contracts will interact looks like this:

Diagram showing Smart Contracts

When the user sends an atomicasset NFT to our BabyChicks contract, we will request a random number from the Oracle. The Oracle will create that random number and call our BabyChick contract asynchronously. Then our BabyChicks contract can take that random value and use it to seed a random number generator. Using that random number generator, we can pick a random template to mint an NFT for the user.

Updating our atomicassets Interface

To start updating the contract, we will need to add onto our atomicassets definitions, but we will also need to add new interfaces and implementations for an orgn interface and Randomness Provider.

First, let’s add onto our existing atomicassets interface header. We’re going to continue to implement parts of the existing atomicassets-interface.hpp from the AtomicAssets Github repository.

In order to check what NFT was sent to our contract, we’ll need the following sectino of the atomicassets-interface:

// Scope: owner
struct assets_s {
    uint64_t asset_id;
    name collection_name;
    name schema_name;
    int32_t template_id;
    name ram_payer;
    std::vector<asset> backed_tokens;
    std::vector<uint8_t> immutable_serialized_data;
    std::vector<uint8_t> mutable_serialized_data;

    uint64_t primary_key() const { return asset_id; };
};
typedef multi_index<name("assets"), assets_s> assets_t;

assets_t get_assets(name acc) {
    return assets_t(ATOMICASSETS_ACCOUNT, acc.value);
}
Enter fullscreen mode Exit fullscreen mode

The get_assets interface will let us find all of the assets that belong to a given account. In our case, that will the be account we deploy the BabyChicks contract too.

Adding the RNG Oracle Interface

The orng interface will provide us with the methods to interact with the wax-orng contract. We’ll need to send that contract a signing value, which will require the following definitions:

namespace orng {
static constexpr name ORNG_CONTRACT = name("orng.wax");

TABLE signvals_a {
    uint64_t signing_value;

    auto primary_key() const { return signing_value; }
};
typedef multi_index<name("signvals.a"), signvals_a> signvals_t;

signvals_t signvals = signvals_t(ORNG_CONTRACT, ORNG_CONTRACT.value);
} // namespace orng
Enter fullscreen mode Exit fullscreen mode

Creating a Randomness Provider

The random oracle will send us a checksum256 random_value when creating a random value for our contract. We will need to take that checksum value and use it to create a random number generator.

Without going too deep into exactly what we are doing, here is the implementation for the RandomnessProvider:

class RandomnessProvider {
  public:
    RandomnessProvider(checksum256 random_seed) {
        raw_values = random_seed.extract_as_byte_array();
        offset = 0;
    }

    uint64_t get_uint64() {
        if (offset > 24) {
            regenerate_raw_values();
        }

        uint64_t value = 0;
        for (int i = 0; i < 8; i++) {
            value = (value << 8) + raw_values[offset];
            offset++;
        }
        return value;
    }

    uint32_t get_rand(uint32_t max_value) {
        return get_uint64() % ((uint64_t)max_value);
    }

  private:
    void regenerate_raw_values() {
        checksum256 new_hash = eosio::sha256((char *)raw_values.data(), 32);
        raw_values = new_hash.extract_as_byte_array();
        offset = 0;
    }

    std::array<uint8_t, 32> raw_values;
    int offset;
};
Enter fullscreen mode Exit fullscreen mode

This class provided methods for getting a random uint64, or use the helper method to get a random value from [0, max_value).

Updating Our Contract Header

Let’s update the header with some additional constants we will need and new class definitions for our contract.

First, we’ll need the template ids for the BabyChick NFTs we will mint:

Let’s add to our babychick_constants:

namespace babychick_constants {
//...
static constexpr name EGG_SCHEMA_NAME = name("chickegg");
static constexpr name BABY_CHICK_SCHEMA_NAME = name("babychick");
static constexpr uint32_t EGG_TEMPLATE_ID = 629366;
static constexpr uint32_t EPIC_TEMPLATE_ID = 629367;
static constexpr uint32_t RARE_TEMPLATE_ID = 629368;
static constexpr uint32_t COMMON_TEMPLATE_ID = 629369;
}
Enter fullscreen mode Exit fullscreen mode

Then, in the contract definition, we can add the following public definitions which we will cover in more depth later:

[[eosio::on_notify("atomicassets::transfer")]] void receive_asset_transfer(
    name from, name to, std::vector<uint64_t> asset_ids, std::string memo
);

ACTION receiverand(uint64_t assoc_id, checksum256 random_value);
using receiverand_action =
    action_wrapper<"receiverand"_n, &babychicks::receiverand>;
Enter fullscreen mode Exit fullscreen mode

We’ll also need some new private helper functions:

void check_unbox_egg(name from, uint64_t asset_id);
void unbox_egg(name from, uint64_t asset_id);
void rng_response_unbox_egg(uint64_t assoc_id, checksum256 random_value);

uint64_t create_signing_value();
Enter fullscreen mode Exit fullscreen mode

Lastly, we will need to create a table to store associate the account and NFT with the values we send to the wax-orng contract:

TABLE rngqueue_s {
    uint64_t assoc_id;
    name from;
    uint32_t timestamp;
    std::string memo;

    uint64_t primary_key() const { return assoc_id; }
    uint64_t by_from() const { return from.value; }
};
typedef multi_index<name("rngqueue"), rngqueue_s> rngqueue_t;
rngqueue_t rngqueue = rngqueue_t(get_self(), get_self().value);
Enter fullscreen mode Exit fullscreen mode

This will let us store the NFT’s asset_id as the assoc_id which we will send to wax-orng. We will also store some additional metadata about the queue state.

Receiving AtomicAssets and Calling the Oracle

When we updated the header file,babychicks.hpp, we added a new definition for hooking onto atomicasset transfers. When an atomicasset NFT is transferred to our contract, this method will run:

[[eosio::on_notify("atomicassets::transfer")]] void receive_asset_transfer(
    name from, name to, vector<uint64_t> asset_ids, string memo
);
Enter fullscreen mode Exit fullscreen mode

Let’s look at the implementation for that and how we will call the wax-orng contract.

/**
 * @brief Listen for NFT transfers to this contract
 *
 * @param from
 * @param to
 * @param asset_ids
 * @param memo
 */
void babychicks::receive_asset_transfer(
    name from, name to, std::vector<uint64_t> asset_ids, std::string memo
) {
    // Ignore EOSIO system account transfers
    const std::set<name> ignore = std::set<name>{
        atomicassets::ATOMICASSETS_ACCOUNT,
        // EOSIO system accounts
        name("eosio.stake"),
        name("eosio.names"),
        name("eosio.ram"),
        name("eosio.rex"),
        name("eosio")};

    if (to != get_self() || ignore.find(from) != ignore.end()) {
        return;
    }

    if (asset_ids.size() != 1) {
        check(false, "Only one asset can be transferred at a time");
    }

    if (memo == "hatch") {
        check_unbox_egg(from, asset_ids[0]);
    } else {
        check(false, "invalid memo");
    }
}
Enter fullscreen mode Exit fullscreen mode

When we receive an NFT transfer, we need to verify that the transfer is coming from a normal account, so we filter out checks from EOSIO system transfers.

Our contract will only handle single NFT transfers, so we fail early if an account sends more than one NFT.

Lastly, we want to ensure that the account meant to send us the NFT, so we expect the transfer to have a memo with “hatch” in it.

You’ll notice that the transfer said nothing about the asset that was transferred to our contract. In check_unbox_egg, we will add additional guards to check that an account hasn’t sent a different NFT to our contract to try to trick us into minting a BabyChick incorrectly.

/**
 * @brief Check if the asset is a ChickEgg and unbox it
 * 
 * @param from 
 * @param asset_id 
 */
void babychicks::check_unbox_egg(name from, uint64_t asset_id) {
    atomicassets::assets_t own_assets = atomicassets::get_assets(get_self());

    // Verify that the asset the user sent us is a ChickEgg
    auto pack_asset_iter =
        own_assets.require_find(asset_id, "Asset could not be found");

    check(
        pack_asset_iter->template_id == babychick_constants::EGG_TEMPLATE_ID,
        "Asset is not a ChickEgg"
    );

    unbox_egg(from, asset_id);
}
Enter fullscreen mode Exit fullscreen mode

Here we use the new atomicassets interface changes that we added to get the contract’s assets, find the asset_id that was transferred. If we find it, we additionally check that the template_id matched the template_id that we minted.

Once which guarded our contract, we can unbox the egg by sending a random request to the oracle:

/**
 * @brief Unbox an egg
 * 
 * @param from 
 * @param asset_id 
 */
void babychicks::unbox_egg(name from, uint64_t asset_id) {
    uint64_t signing_value = create_signing_value();

    rngqueue.emplace(get_self(), [&](auto &_queue) {
        _queue.assoc_id = asset_id;
        _queue.from = from;
        _queue.timestamp = now();
        _queue.memo = "hatch";
    });

    action(
        permission_level{get_self(), name("active")},
        orng::ORNG_CONTRACT,
        name("requestrand"),
        std::make_tuple(asset_id, signing_value, get_self())
    )
        .send();
}
Enter fullscreen mode Exit fullscreen mode

Here we create a signing value to send to the oracle, create an entry in our own contract’s table to store the asset_id, account, plus some metadata, and then send a request random action to the wax-orng contract.

Getting a random value is an asynchronous process, which is why we store the asset_id and account information in our own contract since we will need it to look up who to mint the BabyChick NFT for when the oracle calls our contract with the random value.

The implementation for create_signing_value isn’t too important, but we need to create a unique signing value to send to the wax-orng contract:

/**
 * @brief Create a signing value to use with the oracle request
 * 
 * @return uint64_t 
 */
uint64_t babychicks::create_signing_value() {
    // Create a signing key for the pack
    size_t size = eosio::transaction_size();
    char buf[size];
    uint32_t read = eosio::read_transaction(buf, size);

    check(
        size == read, "Signing value generation: read_transaction() has failed."
    );
    checksum256 tx_id = eosio::sha256(buf, read);
    uint64_t signing_value;
    memcpy(&signing_value, tx_id.data(), sizeof(signing_value));

    // Check if the signing_value was already used.
    // If that is the case, increment the signing_value until a non-used
    // value is found
    while (orng::signvals.find(signing_value) != orng::signvals.end()) {
        signing_value++;
    }

    return signing_value;
}
Enter fullscreen mode Exit fullscreen mode

Minting a BabyChick/Burning a ChickEgg

Once the wax-orng contract creates a random value for us, it will call the following definition that we added to our header file:

ACTION receiverand(uint64_t assoc_id, checksum256 random_value);
using receiverand_action =
    action_wrapper<"receiverand"_n, &babychicks::receiverand>;
Enter fullscreen mode Exit fullscreen mode

The flow for how we handle random values is similar to how we handled atomicassets transfers: first we will check that the function is being called correctly, then we will burn the egg NFT and mint a BabyChick NFT.

The implementation for the receiverand function looks like:

/**
 * @brief Determine how to handle the Oracle response
 * 
 * @param assoc_id 
 * @param random_value 
 * @return ACTION 
 */
ACTION babychicks::receiverand(uint64_t assoc_id, checksum256 random_value) {
    // ensure that only the WAX RNG oracle can call this function
    check(
        has_auth(orng::ORNG_CONTRACT) || has_auth(get_self()),
        "Caller must be WAX RNG oracle"
    );

    auto randomqueue_itr = rngqueue.require_find(
        assoc_id, "Could not find associated randomness id"
    );

    if (randomqueue_itr->memo == "hatch") {
        rng_response_unbox_egg(assoc_id, random_value);
    } else {
        check(false, "invalid memo");
    }
}
Enter fullscreen mode Exit fullscreen mode

First, we check that the correct account is calling this function. Only the wax-orng account or the account the contract is deployed to can call the function. We allow the contract owner to call the function to help with debugging.

Once we have checked the caller, we can verify that the assoc_id actually has an entry in our table. We used the asset_id for this.

Finally, just like how we had a memo for the initial NFT transfer, we added the memo to the table. This helps us route what to do with the random value we were given.

Next, let’s implement the rng_response_unbox_egg which will determine which template to mint, call a function to mint the BabyChick, and then burn the ChickEgg NFT:

/**
 * @brief determine a random number and mint a BabyChick
 * with a random template id
 * 
 * @param assoc_id 
 * @param random_value 
 */
void babychicks::rng_response_unbox_egg(
    uint64_t assoc_id, checksum256 random_value
) {
    auto randomqueue_itr = rngqueue.require_find(
        assoc_id, "Could not find the associated randomness id"
    );

    RandomnessProvider randomness_provider(random_value);
    uint16_t random_range = randomness_provider.get_rand(10);

    // Mint BabyChick
    if (random_range >= 9) {
        mint_baby_chick(
            randomqueue_itr->from, babychick_constants::EPIC_TEMPLATE_ID
        );
    } else if (random_range > 5) {
        mint_baby_chick(
            randomqueue_itr->from, babychick_constants::RARE_TEMPLATE_ID
        );
    } else {
        mint_baby_chick(
            randomqueue_itr->from, babychick_constants::COMMON_TEMPLATE_ID
        );
    }

    // Burn the ChickEgg
    action(
        permission_level{get_self(), name("active")},
        atomicassets::ATOMICASSETS_ACCOUNT,
        name("burnasset"),
        std::make_tuple(get_self(), assoc_id)
    )
        .send();

    // Delete table entry
    rngqueue.erase(randomqueue_itr);
}
Enter fullscreen mode Exit fullscreen mode

Here, we use the RandomnessProvider to take the checksum256 value and provide a class that will let us get a random value from 0 to 10.

We the determine which template to use by using the random value. Since it is giving us a number from 0 (inclusive) to 10 (exclusive), we can check if that number is greater than some value.

In the above implementation, we have:

  • 9 - 10% chance - Epic template
  • 5, 6, 7, 8 - 40% - Rare template
  • 0, 1, 2, 3, 4 - 50% - Common template

Lastly, let’s mint that BabyChick!

/**
 * @brief Mint a BabyChick NFT
 * 
 * @param receiver 
 * @param template_id 
 */
void babychicks::mint_baby_chick(name receiver, uint32_t template_id) {
    action(
        permission_level{get_self(), name("active")},
        atomicassets::ATOMICASSETS_ACCOUNT,
        name("mintasset"),
        std::make_tuple(
            get_self(),
            babychick_constants::COLLECTION_NAME.value,
            babychick_constants::BABY_CHICK_SCHEMA_NAME.value,
            template_id,
            receiver,
            // immutable data
            (atomicassets::ATTRIBUTE_MAP){{"created_at", (uint64_t)now()}},
            // mutable data
            (atomicassets::ATTRIBUTE_MAP){},
            // backing assets
            (std::vector<asset>){}
        )
    )
        .send();
}
Enter fullscreen mode Exit fullscreen mode

This is very similar to how we minted a ChickEgg in the previous sections.

Building

Let’s build the contract to verify that it compiles:

( cd ./build && cmake .. && make )
Enter fullscreen mode Exit fullscreen mode

If you need to rebuild the contract, you may want to delete the build targets to ensure that the build is up to date:

rm ./build/babychicks/CMakeFiles/babychicks.dir/babychicks.obj
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this section, we went over implementing a listener to AtomicAsset transfers, calling the RNG Oracle, and then using the random value provided to mint a random BabyChick.

Next, we’ll deploy and interact with our contract.

Next post: Interacting with the Hatch Action

E-book

Get this entire WAX tutorial as an e-book on Amazon.

Additional links

💖 💪 🙅 🚩
idmontie
Ivan Montiel

Posted on August 6, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related