Enhancing NFTs with Random Numbers and Delegation

Enhancing NFTs with Random Numbers and Delegation

Featuring API3's QRNG, Delegate.xyz and the Pudgy Penguins

When NFT projects become stale, the community asks, "Can the devs do something?" So, I wanted to show a fun way anybody can bring value to an existing project. My idea was to take the Lego approach, which means building something new with existing blocks; in this case, the blocks are already deployed smart contracts you can leverage for your own ideas!

I like the Pudgy Penguin IP and community; they are a source of inspiration for me. A very welcoming and dynamic group of members that maintains three differently deployed contracts. They have the genesis collection of the Pudgy Penguins, the 2nd drop of the Lil Pudgys, and the 3rd drop of the Pudgy Rods(Rogs).

So, I looked at their contracts and got an idea. Why not use the Rogs to go fishing in the Arctic? How would this work? Let's break down the tools.

Companion Video

If you are more of a visual learner, check out this video, where I show you how to implement everything step-by-step!

Required Skills

  • Deploying a smart contract on a framework (e.g., Hardhat, Truffle, or Foundry)
  • Familiarity with NFT contracts
  • Being comfortable with a block explorer (e.g., Etherscan)

What Problems Do We Have to Solve?

Before we start coding, let's review some possible issues we will encounter and think about how to solve them.

Preventing Holders From Bypassing Fishing Limits

Holders of existing Rogs NFTs can go fishing and randomly catch whatever is floating in the ocean. Depending on the amount of Rod NFTs they hold, that would be how many items they could catch in a fishing session (i.e., 5 Rod NFTs = 5 caught items). But how could I limit somebody from spamming the fishing function? I could create a mapping to the wallet, but that doesn't stop somebody from moving the tokens to another wallet to fish again right away.

After inspecting the smart contract, I saw that the Rogs use the ERC721 Enumerable library, which is a great way to keep track of the total supply. It is a bit more expensive than the popular ERC721A contract but has the advantage of the tokenOfOwnerByIndex function. This function allows us to check a wallet for the number of NFTs it holds for a specific project. It also keeps track of the token IDs of each NFT, so if a user owns Rogs #123, #498, and #8372, it returns an array with these three NFT IDs. This way, we can check if someone has already used an NFT independently of the wallet that holds it.

    /**
     * @dev See {IERC721Enumerable-tokenOfOwnerByIndex}.
     */
    function tokenOfOwnerByIndex(address owner, uint256 index) public view virtual returns (uint256) {
        if (index >= balanceOf(owner)) {
            revert ERC721OutOfBoundsIndex(owner, index);
        }
        return _ownedTokens[owner][index];
    }

Now that we figured out a way to keep track of used NFTs, we must decide the time system for the quest. I'm not too fond of daily quests because, after time, they feel like homework instead of something fun. That's why I decided for once a week.

I also wanted to utilize the Pudgy NFTs in the reset time to make things more dynamic. They would help repair the Rogs so that people could reuse them quicker. For each lil Pudgy in the wallet, I would subtract one hour from the wait time.

Upon inspecting holders, I noticed that two wallets owned over 200 lil Pudgys, but there are only 168 hours in 7 days. With my naive implementation, these holders wouldn't have to wait and could spam the fishing. That's why we need to set a minimum waiting time of one day.

Avoiding Deterministic Random Number Generators

Having a mechanic in place, we want our users to have an equal chance and avoid manipulation. Because the blockchain is deterministic, randomness sources like block.timestamp or block.prevrandao are easily manipulated and can be gamed to fish all the good catches.

Instead, we utilize API3s QRNG system for genuine random numbers on-chain. The QRNG works in a two-transaction system. First, we send a random number/s request to the Airnode system and receive the response with the random number in a second transaction. This split amongst multiple transactions makes it very difficult to manipulate the outcome giving everyone a fair chance at a prize.

API3's QRNG does not require a token to work as it is a public good; it only requires some gas funds to pay for the response transactions.

Avoiding to Look Like a Scam

Given that this space has its share of bad actors, the fear of claiming with our cold/asset-filled wallet would require a bit of trust. It is risky for the user to use their wallet on unknown platforms, especially if we are unofficially building off an existing project. Many might hesitate to try our DApp for fear of losing their assets. Delegate.xyz lets us use another "hot wallet" to make our claims without risking our core assets. We integrate this system so that users can use their delegated wallets to go fishing without risking any assets as an available option.

D_D Newsletter CTA

Implementation

Now that we understand which issues might arise, let's build something!

Setting Up the Contract

We follow a simple ERC1155 NFT framework based on the OpenZeppelin wizard. I added strings to create token URIs based on the tokenId of each NFT that would be minted based on the string I passed on the deployment constructor.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Supply.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

contract FishingTreasures is ERC1155, Ownable, Pausable, ERC1155Supply {
    string public TreasureNFT;

    constructor(string memory _treasureNFT) ERC1155(_treasureNFT) {
        TreasureNFT = _treasureNFT;
    }

    //To be modified to fit in project
    function mintBatch(address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data)
        public
        onlyOwner
    {
        _mintBatch(to, ids, amounts, data);
    }

    function setURI(string memory newuri) public onlyOwner {
        _setURI(newuri);
    }

    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }

    // URI override for number schemes
    function uri(uint256 _tokenId) public view override returns (string memory)
    {
        return string(abi.encodePacked(TreasureNFT, Strings.toString(_tokenId), ".json"));
    }

    function _beforeTokenTransfer(address operator, address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data)
        internal
        whenNotPaused
        override(ERC1155, ERC1155Supply)
    {
        super._beforeTokenTransfer(operator, from, to, ids, amounts, data);
    }

    function name() public pure returns (string memory) {
        return "Ocean Treasures";
    }

    function symbol() public pure returns (string memory) {
        return "OT";
    }
}

To interact with the existing contracts, we need to interface with them and get a list of the functions we want to use. For now, we only need to check the balances of the wallets and the tokenOfOwnerByIndex for the ERC721 Enumerable.

For a cleaner layout, we are putting the interfaces in their own file called: IPudgys.sol

We are creating interfaces for both the lil Pudgys (which is ERC721 - so we only need balanceOf) and the Rogs (ERC721Enumerable - so we can grab tokenOfOwnerByIndex and balanceOf)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
interface ILilPengu {
    function balanceOf(address owner) external view returns (uint256 balance);
}

interface IRogs {
    function tokenOfOwnerByIndex(address owner, uint256 index)
        external
        View
        returns (uint256 tokenId);

    function balanceOf(address owner) external view returns (uint256 balance);
}

We will import this file into our NFT contract to reference the ILilPengu and IRogs in our contract.

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Supply.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "./IPudgys.sol"; /* New Addition */

With this import, we can now assign our interfaces to a variable and then update our constructor to input the addresses of the deployed contracts. This way, when we refer to our external contracts, we can reference them with our variable.

    ILilPengu lilPengu;
    IRogs rogs;

    constructor(address _lilPengu, address _rogs, string memory _treasureNFT) ERC1155(_treasureNFT) {
        lilPengu = ILilPengu(_lilPengu);
        rogs = IRogs(_rogs);
        TreasureNFT = _treasureNFT;
    }

When we want to refer to the contract and its interfaced functions, we can call it and assign it to a value.

Example:

//Check how many lil Pudgy NFTs are in the wallet and set to variable
uint256 lilPudgyBalance = lilPengu.balanceOf(msg.sender);

The code so far:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Supply.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "./IPudgys.sol";

contract FishingTreasures is ERC1155, Ownable, Pausable, ERC1155Supply {

    //Objects to Interface with deployed contracts
    ILilPengu lilPengu;
    IRogs rogs;

    //uint256 public nftId;
    string public TreasureNFT;

    constructor(address _lilPengu, address _rogs, string memory _treasureNFT) ERC1155(_treasureNFT) {
        //Set the contract address to the variable
        lilPengu = ILilPengu(_lilPengu);
        rogs = IRogs(_rogs);
        TreasureNFT = _treasureNFT;
    }

    //To be modified to fit in project
    function mintBatch(address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data)
        public
        onlyOwner
    {
        _mintBatch(to, ids, amounts, data);
    }

    function setURI(string memory newuri) public onlyOwner {
        _setURI(newuri);
    }

    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }

    // URI override for number schemes
    function uri(uint256 _tokenId) public view override returns (string memory)
    {
        return string(abi.encodePacked(TreasureNFT, Strings.toString(_tokenId), ".json"));
    }

    function _beforeTokenTransfer(address operator, address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data)
        internal
        whenNotPaused
        override(ERC1155, ERC1155Supply)
    {
        super._beforeTokenTransfer(operator, from, to, ids, amounts, data);
    }

    function name() public pure returns (string memory) {
        return "Ocean Treasures";
    }

    function symbol() public pure returns (string memory) {
        return "OT";
    }
}

Integrating the Random Number Generator

I chose the API3 QRNG for its simplicity, low cost (just gas, no token), and effectiveness in generating the numbers. It works based on a *Request and Recieve" functionality. To receive genuine random numbers, we can't allow a validator to roll back a transaction if they don't like the number they receive. The number is generated across two separate transactions. The first sends the requests, and the second gets the return call.

The following is a basic layout for the random number system:

  • Setup our parameters (i.e., chain-specific parameters, who's paying for the transaction)

  • Requests the random number

  • Receive the random number and do something with it.

Most of this template is not modified, as it is the standard setup for requesting numbers through API3's QRNG.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.9;

//Import out library to use the "request/response" tools    
import "@api3/airnode-protocol/contracts/rrp/requesters/RrpRequesterV0.sol";

contract QRNGExample is RrpRequesterV0{
    //Public Variables to setup
    address public airnode;
    bytes32 public endpointIdUint256Array;
    address public sponsorWallet;

    mapping (bytes32 => bool) public expectingRequestWithIdToBeFulfilled;

    //Events for our Request and Receive
    event RequestedUint256Array(bytes32 indexed requestId, uint256 size);
    event RecievedUint256Array(bytes32 indexed requestId, uint256[] response);

    //Setting up our contract to choose the chain our request will go to
    constructor(address _airnodeRrp) RrpRequesterV0(_airnodeRrp){}

    //SETUP OUR PARAMETERS
    function setRequestParameters(address _airnode, bytes32 _endpointIdUint256Array, address _sponsorWallet) external {
        airnode = _airnode;
        endpointIdUint256Array = _endpointIdUint256Array;
        sponsorWallet = _sponsorWallet;
    }

    //REQUESTS THE RANDOM NUMBER
    function makeRequestUint256Array(uint256 size) external {
        bytes32 requestId = airnodeRrp.makeFullRequest(
            airnode,
            endpointIdUint256Array,
            address(this),
            sponsorWallet,
            address(this),
            this.fulfillUint256Array.selector,
            abi.encode(bytes32("1u"), bytes32("size"), size)
        );
        expectingRequestWithIdToBeFulfilled[requestId] = true;
        //Do our logic to request how many numbers here
        emit RequestedUint256Array(requestId, size);
    }

    //RECEIVES THE RANDOM NUMBER
    function fulfillUint256Array(bytes32 requestId, bytes calldata data) external onlyAirnodeRrp {
        require(expectingRequestWithIdToBeFulfilled[requestId], "Request ID is not known");
        expectingRequestWithIdToBeFulfilled[requestId] = false;
        uint256 qrngUint256Array = abi.decode(data, (uint256[]));
        //Mint out NFTs with these values that we got in return
        emit RecievedUint256Array(requestId, qrngUint256Array);
    }
}

To get the full details about this system, please check docs or video here.

Since getting genuine random numbers is a two-transaction system, we will use this contract as the primary front-facing contract and import the NFT contract into this one. This way, when we request to mint, it will check our balances, see how many assets we own and then make the request based on how many Rogs the wallet controls.

We will import our Fishing Treasures NFT contract to our random number generator contract. Because the Fishing Treasures contract takes in arguments in the constructor, we must pass those arguments from our new front-facing contract to our imported contract. With most NFT contracts, we have a mint function that takes in the msg.sender to mint the NFT to the wallet.

In this case, the QRNG request is a two-step process, so when we request a number, msg.sender is the wallet address. Yet, when we receive the random number in return, the msg.sender is NOT the original address but the contract address returning the generated number for us (not the user's wallet). The way the QRNG system works, there is a byte ID tied to the request of our random number. So a mapping from the request Identifier to the user's wallet will help us keep track of who's wallet requested and who to mint that returned number to (for minting).

//SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
import "@api3/airnode-protocol/contracts/rrp/requesters/RrpRequesterV0.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
//importing the OG NFT Contract we built
import "./FishingTreasures.sol";

        //Rename the contract           //importing NFT functions to our contract
contract TreasureHunt is RrpRequesterV0, Ownable, FishingTreasures {
    event RequestedUint256Array(bytes32 indexed requestId, uint256 size);
    event ReceivedUint256Array(bytes32 indexed requestId, uint256[] response);

    address public airnode;
    bytes32 public endpointIdUint256Array;
    address public sponsorWallet;

    mapping(bytes32 => bool) public expectingRequestWithIdToBeFulfilled;
    /* NOTE! IN A TWO TRANSACTION SYSTEM:
       msg.sender is the wallet on the first transaction,
       but msg.sender is the contract that returns the call in the 2nd
       transaction.  So we will need a way to keep a record of the
       Original caller */
    //Maps the requestId to the address that made the request (original msg.sender)
    mapping(bytes32 => address) requestToSender;

    /* Now that this is the "front facing contract," we must fulfill all constructor
       requirement, such as the airnode as well as our original NFT contract requirements
       that get passed down to the contract as shown below  */
    constructor(
        //for the requester input
        address _airnodeRrp,
        //for the nft contract
        address _pudgyPengu,
        address _lilPengu,
        address _rogs,
        string memory _nftURI
    )
        RrpRequesterV0(_airnodeRrp)
        FishingTreasures(_pudgyPengu, _lilPengu, _rogs, _nftURI)
    {}

    function setRequestParameters(
        address _airnode,
        bytes32 _endpointIdUint256Array,
        address _sponsorWallet
    ) external onlyOwner {
        airnode = _airnode;
        endpointIdUint256Array = _endpointIdUint256Array;
        sponsorWallet = _sponsorWallet;
    }

    function makeRequestUint256Array(uint256 size, address _caller) internal {
        bytes32 requestId = airnodeRrp.makeFullRequest(
            airnode,
            endpointIdUint256Array,
            address(this),
            sponsorWallet,
            address(this),
            this.fulfillUint256Array.selector,
            // Using Airnode ABI to encode the parameters
            abi.encode(bytes32("1u"), bytes32("size"), size)
        );
        expectingRequestWithIdToBeFulfilled[requestId] = true;
        //code the msg.sender to the requestId
        requestToSender[requestId] = _caller;
        emit RequestedUint256Array(requestId, size);
    }

    function fulfillUint256Array(
        bytes32 requestId,
        bytes calldata data
    ) external onlyAirnodeRrp {
        require(
            expectingRequestWithIdToBeFulfilled[requestId],
            "Request ID not known"
        );
        expectingRequestWithIdToBeFulfilled[requestId] = false;
        uint256[] memory qrngUint256Array = abi.decode(data, (uint256[]));

        emit ReceivedUint256Array(requestId, qrngUint256Array);
    }
}

Prepping Our Logic for The Mint

So far, we have set up two contracts:

  1. The NFT contract that will handle the minting based on values given (Fishing Treasures)

  2. The random number requester contract will request and receive random numbers (Treasure Hunt)

We now need to set up the logic in the random number request to see how many random numbers we will be requesting. In the Fishing Treasures contract, we are interfacing the two already deployed contracts to use the functions in our contract.

    //Reminder that we have this in Fishing Treasures
    ILilPengu lilPengu;
    IRogs rogs;
    //////////
    constructor(address _lilPengu, address _rogs, string memory _treasureNFT) ERC1155(_treasureNFT) {
        lilPengu = ILilPengu(_lilPengu);
        rogs = IRogs(_rogs);
        TreasureNFT = _treasureNFT;
    }

We will reference lilPengu and rogs for our two pudgy penguin contracts. In our front-facing contract, Treasure Hunt, we will check the wallet's balance and request the mints to perform our logic.

As stated in the preface, we want each rog held by the wallet to get one chance at an item. 1 rog = 1 item, 10 rogs = 10 items and so on. We will also want to account for the tokenId of each rog being used so we don't get spammed with requests for the same NFT.

We will create a function that will go through this logic, get all the parameters we need, and then pass that to our makeRequestUint256Array function (the standard function in our boilerplate Random number requester code).

We will call it letsGoFishing and it should have the following features:

  • Get the balance of each asset

  • Calculate the cooldown bonus for lil pudgy held

  • Give a fail-safe on a timer in case there are large holders

  • Go through the rocks to see if any have been used previously. If not, set them to use

  • Take the total amount of "rogs" and request that many random numbers

  • Emit an event

function letsGoFishing() external {
        //go through the amounts of rogs for number request
        uint256 lilPudgyBalance = lilPengu.balanceOf(msg.sender);
        uint256 rogBalance = rogs.balanceOf(msg.sender);
        //reset the amount to 0
        uint256 amount = 0;
        //calculate the balance of lilpudgys to make sure there is a delay if too many pudgys
        uint256 lilPudgyTimer = (lilPudgyBalance * 1 hours);
        //if the timer is greater than 7 days, set it to 6 days (some holders have a large amount)
        if (lilPudgyTimer > 7 days) {
            lilPudgyTimer = 6 days;
        }
        // loop over the balance and get the token ID owned by `sender` at a given `index` of its token list.
        for (uint256 i = 0; i < rogBalance; i++) {
            uint256 tokenId = rogs.tokenOfOwnerByIndex(msg.sender, i);
            // if the tokenId has not been claimed, increase the amount
            of (rogsData[tokenId] < block.timestamp) {
                amount += 1;
                //For testing purposes, just limit to 2 minutes
                rogsData[tokenId] =  ((block.timestamp + 2 minutes) - lilPudgyTimer); 
                // console.log("Base Reset time:", (block.timestamp + 7 days));
                // console.log("Holder Reset time:", rogsData[tokenId].timestamp);
            }
        }
        makeRequestUint256Array(amount, msg.sender);
        emit WentFishing(msg.sender, amount);
    }

Our fail-safe for whales is this piece of code. If the wallet holds a massive amount of lil Pudgy NFTs, the wait time may turn negative (comparing it against the current block.timestamp keeps of

if (lilPudgyTimer > 7 days) {
            lilPudgyTimer = 6 days;
        }

In the 2nd to last function, makeRequestUint256Array, we are passing the amount and the msg.sender address.

We are modifying this to accept the caller because we want to pass our address in with the request identifier so we can keep track of the msg.sender address. Since each request is unique, we can put the address in the requestToSender[requestId].

function makeRequestUint256Array(uint256 size, address _caller) internal {
        bytes32 requestId = airnodeRrp.makeFullRequest(
            airnode,
            endpointIdUint256Array,
            address(this),
            sponsorWallet,
            address(this),
            this.fulfillUint256Array.selector,
            // Using Airnode ABI to encode the parameters
            abi.encode(bytes32("1u"), bytes32("size"), size)
        );
        expectingRequestWithIdToBeFulfilled[requestId] = true;
        //code the msg.sender to the requestId
        requestToSender[requestId] = _caller; //<<<<<<<<<<<<<
        emit RequestedUint256Array(requestId, size);
    }

Once we have made the request, the external contract returns the random numbers by calling the fulfillUint256Array.

Once we receive this number, we will:

  1. Loop through the lists of random numbers in the array and shorten it (the numbers returned are huge and for this demo, we are only working with 5 NFTs, so we will modular 5. This will also include 0, so we will +1 to offset the 0 and start it at 1)

  2. Set the new array of resized numbers to a public variable so we can view what we received (just for our own viewing purposes)

  3. Call a mint function that mints our NFTs with our new numbers (still needs to be built).

  4. Emit an event

function fulfillUint256Array(
        bytes32 requestId,
        bytes calldata data
    ) external onlyAirnodeRrp {
        require(
            expectingRequestWithIdToBeFulfilled[requestId],
            "Request ID not known"
        );
        expectingRequestWithIdToBeFulfilled[requestId] = false;
        uint256[] memory qrngUint256Array = abi.decode(data, (uint256[]));
        for (uint i = 0; i < qrngUint256Array.length; i++) {
            qrngUint256Array[i] = (qrngUint256Array[i] % 5) + 1;
        }
        arrayOfRandoms = qrngUint256Array; // just public call to see latest values
        // Call in the mapping of our original msg.sender with the returned requestId
        reelInPrize(requestToSender[requestId], qrngUint256Array);

        emit ReceivedUint256Array(requestId, qrngUint256Array);
    }

Implementing the Minting Function

We will return to our Fishing Treasures contract to create this minting function.

In the function, we will:

  1. Create a copy of the array of random numbers

  2. Loop through the size of the array to see how many total numbers we have of each NFT

  3. Batch mint to the original msg.sender, each with the random NFT ID and the amounts.

  4. Emit an event

function reelInPrize(address _requester ,uint256[] memory _array) internal {
        uint256[] memory amounts = new uint256[](_array.length);
        for(uint i = 0; i < _array.length; i++) {
            amounts[i] = 1;
        }
        _mintBatch(_requester, _array, amounts, "");
        emit TreasureFound(_requester, _array, amounts);
    }

The full code for the NFT portion contract Fishing Treasures should look like the following:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Supply.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "./IPudgys.sol";

contract FishingTreasures is ERC1155, Ownable, Pausable, ERC1155Supply {

    event TreasureFound(address _from, uint256[] _tokenIds, uint256[] _amounts);

    IPudgyPengu pudgyPengu;
    ILilPengu lilPengu;
    IRogs rogs;

    //uint256 public nftId;
    string public TreasureNFT;

    //token ID to block.timestamp
    mapping(uint256 => uint256) public rogsData;

    constructor(address _pudgyPengu, address _lilPengu, address _rogs, string memory _treasureNFT) ERC1155(_treasureNFT) {
        pudgyPengu = IPudgyPengu(_pudgyPengu);
        lilPengu = ILilPengu(_lilPengu);
        rogs = IRogs(_rogs);
        TreasureNFT = _treasureNFT;
    }

    function setURI(string memory newuri) public onlyOwner {
        _setURI(newuri);
    }

    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }

    //@Dev: This is called once the random number array is received to mint the NFTs
    //Requester is the saved msg.sender from the random number request
    //as this function is called from the airnode
    function reelInPrize(address _requester ,uint256[] memory _array) internal {
        uint256[] memory amounts = new uint256[](_array.length);
        for(uint i = 0; i < _array.length; i++) {
            amounts[i] = 1;
        }
        //call the fishing mint function
        _mintBatch(_requester, _array, amounts, "");
        emit TreasureFound(_requester, _array, amounts);
    }

    // URI override for number schemes
    function uri(uint256 _tokenId) public view override returns (string memory)
    {
        return string(abi.encodePacked(TreasureNFT, Strings.toString(_tokenId), ".json"));
    }

    function _beforeTokenTransfer(address operator, address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data)
        internal
        whenNotPaused
        override(ERC1155, ERC1155Supply)
    {
        super._beforeTokenTransfer(operator, from, to, ids, amounts, data);
    }

    function name() public pure returns (string memory) {
        return "Ocean Treasures";
    }

    function symbol() public pure returns (string memory) {
        return "OT";
    }
}

The front-facing Treasure Hunt contract should look like the following:

//SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
import "@api3/airnode-protocol/contracts/rrp/requesters/RrpRequesterV0.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "./FishingTreasures.sol";

contract TreasureHunt is RrpRequesterV0, Ownable, FishingTreasures {
    event RequestedUint256Array(bytes32 indexed requestId, uint256 size);
    event ReceivedUint256Array(bytes32 indexed requestId, uint256[] response);
    event WentFishing(address _from, uint256 amount);

    address public airnode;
    bytes32 public endpointIdUint256Array;
    address public sponsorWallet;

    uint256[] public arrayOfRandoms;

    mapping(bytes32 => bool) public expectingRequestWithIdToBeFulfilled;
    //Mapping that maps the requestId to the address that made the request
    mapping(bytes32 => address) requestToSender;

    constructor(
        address _airnodeRrp,
        address _pudgyPengu,
        address _lilPengu,
        address _rogs,
        string memory _nftURI
    )
        RrpRequesterV0(_airnodeRrp)
        FishingTreasures(_pudgyPengu, _lilPengu, _rogs, _nftURI)
    {}

    function setRequestParameters(
        address _airnode,
        bytes32 _endpointIdUint256Array,
        address _sponsorWallet
    ) external onlyOwner {
        airnode = _airnode;
        endpointIdUint256Array = _endpointIdUint256Array;
        sponsorWallet = _sponsorWallet;
    }

    function letsGoFishing() external {
        //go through the amounts of rogs for number request
        uint256 lilPudgyBalance = lilPengu.balanceOf(msg.sender);
        uint256 rogBalance = rogs.balanceOf(msg.sender);
        //reset the amount to 0
        uint256 amount = 0;
        //calculate the balance of lilpudgys to make sure there is a delay if too many pudgys
        uint256 lilPudgyTimer = (lilPudgyBalance * 1 hours);
        //if the timer is greater than 7 days, set it to 6 days (some holders have a large amount)
        if (lilPudgyTimer > 7 days) {
            lilPudgyTimer = 6 days;
        }
        // loop over the balance and get the token ID owned by `sender` at a given `index` of its token list.
        for (uint256 i = 0; i < rogBalance; i++) {
            uint256 tokenId = rogs.tokenOfOwnerByIndex(msg.sender, i);
            // if the tokenId has not been claimed, increase the amount
            of (rogsData[tokenId] < block.timestamp) {
                amount += 1;
                //rogsData[tokenId] =  ((block.timestamp + 7 days) - lilPudgyTimer);
                rogsData[tokenId] =  ((block.timestamp + 2 minutes) - lilPudgyTimer);
            }
        }
        makeRequestUint256Array(amount, msg.sender);
        emit WentFishing(msg.sender, amount);
    }

    function makeRequestUint256Array(uint256 size, address _caller) internal {
        bytes32 requestId = airnodeRrp.makeFullRequest(
            airnode,
            endpointIdUint256Array,
            address(this),
            sponsorWallet,
            address(this),
            this.fulfillUint256Array.selector,
            // Using Airnode ABI to encode the parameters
            abi.encode(bytes32("1u"), bytes32("size"), size)
        );
        expectingRequestWithIdToBeFulfilled[requestId] = true;
        //code the msg.sender to the requestId
        requestToSender[requestId] = _caller;
        emit RequestedUint256Array(requestId, size);
    }

    function fulfillUint256Array(
        bytes32 requestId,
        bytes calldata data
    ) external onlyAirnodeRrp {
        require(
            expectingRequestWithIdToBeFulfilled[requestId],
            "Request ID not known"
        );
        expectingRequestWithIdToBeFulfilled[requestId] = false;
        uint256[] memory qrngUint256Array = abi.decode(data, (uint256[]));
        for (uint i = 0; i < qrngUint256Array.length; i++) {
            qrngUint256Array[i] = (qrngUint256Array[i] % 5) + 1;
        }
        arrayOfRandoms = qrngUint256Array; // just public call
        reelInPrize(requestToSender[requestId], qrngUint256Array);

        emit ReceivedUint256Array(requestId, qrngUint256Array);
    }
}

We could deploy now but still need the holder's trust and improve the contract's security. To build trust, we need To minimize the risk of NFT holders when using our DApp.

D_D Newsletter CTA

Adding Wallet Delegation

The crypto space is flooded with countless stories of drained wallets because they connect their asset-filled wallets and unknowingly give allowances. As developers, we must provide our users with the best experience possible by protecting them from making such mistakes.

With a delegation system, we can create a fresh wallet (a hot wallet with no assets inside) to claim and interact on behalf of the cold storage with the valuable assets with claims. This way, if a user mistakenly gives access to the wallet, there are no assets to take. Delegate.xyz is an open-source public good available for all to use. It comes with an SDK for the smart contract and the front end.

To enable delegation in our smart contract, we will need to import the delegation proxy interface to our code.

You can find the code here: here

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.9;

/**
 * @title An immutable registry contract to be deployed as a standalone primitive
 * @dev See EIP-5639, new project launches can read previous cold wallet -> hot wallet delegations
 * from here and integrate those permissions into their flow
 */
interface IDelegationRegistry {
    /// @notice Delegation type
    enum DelegationType {
        NONE,
        ALL,
        CONTRACT,
        TOKEN
    }

    /// @notice Info about a single delegation used for onchain enumeration
    struct DelegationInfo {
        DelegationType type_;
        address vault;
        address delegate;
        address contract_;
        uint256 tokenId;
    }

    /// @notice Info about a single contract-level delegation
    struct ContractDelegation {
        address contract_;
        address delegate;
    }

    /// @notice Info about a single token-level delegation
    struct TokenDelegation {
        address contract_;
        uint256 tokenId;
        address delegate;
    }

    /// @notice Emitted when a user delegates their entire wallet
    event DelegateForAll(address vault, address delegate, bool value);

    /// @notice Emitted when a user delegates a specific contract
    event DelegateForContract(address vault, address delegate, address contract_, bool value);

    /// @notice Emitted when a user delegates a specific token
    event DelegateForToken(address vault, address delegate, address contract_, uint256 tokenId, bool value);

    /// @notice Emitted when a user revokes all delegations
    event RevokeAllDelegates(address vault);

    /// @notice Emitted when a user revoes all delegations for a given delegate
    event RevokeDelegate(address vault, address delegate);

    /**
     * -----------  WRITE -----------
     */

    /**
     * @notice Allow the delegate to act on your behalf for all contracts
     * @param delegate The hot wallet to act on your behalf
     * @param value Whether to enable or disable delegation for this address, true for setting and false for revoking
     */
    function delegateForAll(address delegate, bool value) external;

    /**
     * @notice Allow the delegate to act on your behalf for a specific contract
     * @param delegate The hot wallet to act on your behalf
     * @param contract_ The address for the contract you're delegating
     * @param value Whether to enable or disable delegation for this address, true for setting and false for revoking
     */
    function delegateForContract(address delegate, address contract_, bool value) external;

    /**
     * @notice Allow the delegate to act on your behalf for a specific token
     * @param delegate The hot wallet to act on your behalf
     * @param contract_ The address for the contract you're delegating
     * @param tokenId The token id for the token you're delegating
     * @param value Whether to enable or disable delegation for this address, true for setting and false for revoking
     */
    function delegateForToken(address delegate, address contract_, uint256 tokenId, bool value) external;

    /**
     * @notice Revoke all delegates
     */
    function revokeAllDelegates() external;

    /**
     * @notice Revoke a specific delegate for all their permissions
     * @param delegate The hot wallet to revoke
     */
    function revokeDelegate(address delegate) external;

    /**
     * @notice Remove yourself as a delegate for a specific vault
     * @param vault The vault which delegated to the msg.sender, and should be removed
     */
    function revokeSelf(address vault) external;

    /**
     * -----------  READ -----------
     */

    /**
     * @notice Returns all active delegations a given delegate is able to claim on behalf of
     * @param delegate The delegate that you would like to retrieve delegations for
     * @return info Array of DelegationInfo structs
     */
    function getDelegationsByDelegate(address delegate) external view returns (DelegationInfo[] memory);

    /**
     * @notice Returns an array of wallet-level delegates for a given vault
     * @param vault The cold wallet who issued the delegation
     * @return addresses Array of wallet-level delegates for a given vault
     */
    function getDelegatesForAll(address vault) external view returns (address[] memory);

    /**
     * @notice Returns an array of contract-level delegates for a given vault and contract
     * @param vault The cold wallet who issued the delegation
     * @param contract_ The address for the contract you're delegating
     * @return addresses Array of contract-level delegates for a given vault and contract
     */
    function getDelegatesForContract(address vault, address contract_) external view returns (address[] memory);

    /**
     * @notice Returns an array of contract-level delegates for a given vault's token
     * @param vault The cold wallet who issued the delegation
     * @param contract_ The address for the contract holding the token
     * @param tokenId The token id for the token you're delegating
     * @return addresses Array of contract-level delegates for a given vault's token
     */
    function getDelegatesForToken(address vault, address contract_, uint256 tokenId)
        external
        View
        returns (address[] memory);

    /**
     * @notice Returns all contract-level delegations for a given vault
     * @param vault The cold wallet who issued the delegations
     * @return delegations Array of ContractDelegation structs
     */
    function getContractLevelDelegations(address vault)
        external
        View
        returns (ContractDelegation[] memory delegations);

    /**
     * @notice Returns all token-level delegations for a given vault
     * @param vault The cold wallet who issued the delegations
     * @return delegations Array of TokenDelegation structs
     */
    function getTokenLevelDelegations(address vault) external view returns (TokenDelegation[] memory delegations);

    /**
     * @notice Returns true if the address is delegated to act on the entire vault
     * @param delegate The hot wallet to act on your behalf
     * @param vault The cold wallet who issued the delegation
     */
    function checkDelegateForAll(address delegate, address vault) external view returns (bool);

    /**
     * @notice Returns true if the address is delegated to act on your behalf for a token contract or an entire vault
     * @param delegate The hot wallet to act on your behalf
     * @param contract_ The address for the contract you're delegating
     * @param vault The cold wallet who issued the delegation
     */
    function checkDelegateForContract(address delegate, address vault, address contract_)
        external
        View
        returns (bool);

    /**
     * @notice Returns true if the address is delegated to act on your behalf for a specific token, the token's contract, or an entire vault
     * @param delegate The hot wallet to act on your behalf
     * @param contract_ The address for the contract you're delegating
     * @param tokenId The token id for the token you're delegating
     * @param vault The cold wallet who issued the delegation
     */
    function checkDelegateForToken(address delegate, address vault, address contract_, uint256 tokenId)
        external
        View
        returns (bool);
}

We copy and paste the code into our code repo and import it into our primary contract. For this example, create a folder called Delegate and paste the code into IDelegationRegistry.sol. Then import the interface into our main contract.

//SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
import "@api3/airnode-protocol/contracts/rrp/requesters/RrpRequesterV0.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "./FishingTreasures.sol";
import "./Delegate/IDelegationRegistry.sol";

For simplicity of this tutorial, we are going to use a delegationForAll option (details in the delegate.xyz docs so it will check for both our Lil penguins and Rogs NFT contracts.

We will set out an interface to a variable (so we can refer to it in our contract)

IDelegationRegistry public immutable registry;

In our constructor, we must set our registration for the chain we are deploying to. You can find the current list here.

constructor(
        address _registry, //<<< New addition to our constructor
        address _airnodeRrp,
        address _pudgyPengu,
        address _lilPengu,
        address _rogs,
        string memory _nftURI
    )
        RrpRequesterV0(_airnodeRrp)
        FishingTreasures(_pudgyPengu, _lilPengu, _rogs, _nftURI)
    {
        registry = IDelegationRegistry(_registry); //<< Setting it to our object
    }

Once we have this setup, we can check the wallet claiming a delegation status within our contract by comparing the msg.sender (the wallet set to the delegate) against the cold storage that holds the assets (the vault). If the validation passes, we move on with the rest of the functions.

if (_vault != address(0)) { 
            bool isDelegateValid = registry.checkDelegateForContract(msg.sender, _vault, address('address of the contract asset delegated'));
            require(isDelegateValid, "invalid delegate-vault pairing");
        }

Putting it all together, here is the final code for the front-facing NFT contract. The biggest thing to note is that we've created two functions that do the same.

One function is for users that connect their wallet with assets and another for users that have delegated their wallet and using the hot wallet (passing the address of the wallet with the asset as an argument - vault).

Also, notice that there is only one receive function, as we only need to worry about the delegation check during the initial claim. We then pass the asset-holding wallet through the calls, so there is no need to add anything to receive our NFT.

//SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
import "@api3/airnode-protocol/contracts/rrp/requesters/RrpRequesterV0.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "./FishingTreasures.sol";
import "./Delegate/IDelegationRegistry.sol";

contract TreasureHunt is RrpRequesterV0, Ownable, FishingTreasures {
    event RequestedUint256Array(bytes32 indexed requestId, uint256 size);
    event ReceivedUint256Array(bytes32 indexed requestId, uint256[] response);
    event WentFishing(address _from, uint256 amount);

    address public airnode;
    bytes32 public endpointIdUint256Array;
    address public sponsorWallet;

    uint256 public singleNumber;
    uint256[] public arrayOfRandoms;

    /// @notice address of the DelegationRegistry
    /// @dev see https://delegate.xyz/ for more details
    IDelegationRegistry public immutable registry;

    mapping(bytes32 => bool) public expectingRequestWithIdToBeFulfilled;
    //Mapping that maps the requestId to the address that made the request
    mapping(bytes32 => address) requestToSender;

    constructor(
        address _registry,
        address _airnodeRrp,
        address _pudgyPengu,
        address _lilPengu,
        address _rogs,
        string memory _nftURI
    )
        RrpRequesterV0(_airnodeRrp)
        FishingTreasures(_pudgyPengu, _lilPengu, _rogs, _nftURI)
    {
        registry = IDelegationRegistry(_registry);
    }

    function setRequestParameters(
        address _airnode,
        bytes32 _endpointIdUint256Array,
        address _sponsorWallet
    ) external onlyOwner {
        airnode = _airnode;
        endpointIdUint256Array = _endpointIdUint256Array;
        sponsorWallet = _sponsorWallet;
    }

    function letsGoFishing() external {
        //go through the amounts of rogs for number request
        uint256 lilPudgyBalance = lilPengu.balanceOf(msg.sender);
        uint256 rogBalance = rogs.balanceOf(msg.sender);
        //reset the amount to 0
        uint256 amount = 0;
        //calculate the balance of lilpudgys to make sure there is a delay if too many pudgys
        uint256 lilPudgyTimer = (lilPudgyBalance * 1 hours);
        //if the timer is greater than 7 days, set it to 6 days (some holders have a large amount)
        if (lilPudgyTimer > 7 days) {
            lilPudgyTimer = 6 days;
        }
        // loop over the balance and get the token ID owned by `sender` at a given `index` of its token list.
        for (uint256 i = 0; i < rogBalance; i++) {
            uint256 tokenId = rogs.tokenOfOwnerByIndex(msg.sender, i);
            // if the tokenId has not been claimed, increase the amount
            of (rogsData[tokenId] < block.timestamp) {
                amount += 1;

                //rogsData[tokenId] =  ((block.timestamp + 7 days) - lilPudgyTimer);
                rogsData[tokenId] =  ((block.timestamp + 2 minutes) - lilPudgyTimer); //Make sure this works
            }
        }
        makeRequestUint256Array(amount, msg.sender);
        emit WentFishing(msg.sender, amount);
    }

    function letsGoFishingDelegate(address _vault) external {
        // Check if msg.sender is a permitted delegate of the vault address
        if (_vault != address(0)) { 
            bool isDelegateValid = registry.checkDelegateForContract(msg.sender, _vault, address(rogs));
            require(isDelegateValid, "invalid delegate-vault pairing");
        }
        //go through the amounts of rogs for number request
        uint256 lilPudgyBalance = lilPengu.balanceOf(_vault);
        uint256 rogBalance = rogs.balanceOf(_vault);
        //reset the amount to 0
        uint256 amount = 0;
        //calculate the balance of lilpudgys to make sure there is a delay if too many pudgys
        uint256 lilPudgyTimer = (lilPudgyBalance * 1 hours);
        //if the timer is greater than 7 days, set it to 6 days (some holders have a large amount)
        if (lilPudgyTimer > 7 days) {
            lilPudgyTimer = 6 days;
        }
        // loop over the balance and get the token ID owned by `sender` at a given `index` of its token list.
        for (uint256 i = 0; i < rogBalance; i++) {
            uint256 tokenId = rogs.tokenOfOwnerByIndex(_vault, i);
            // if the tokenId has not been claimed, increase the amount
            of (rogsData[tokenId] < block.timestamp) {
                amount += 1;
                //rogsData[tokenId] =  ((block.timestamp + 7 days) - lilPudgyTimer);
                rogsData[tokenId] =  ((block.timestamp + 2 minutes) - lilPudgyTimer); //Make sure this works
            }
        }
        makeRequestUint256Array(amount, _vault);  // We pass the vault address instead of msg.sender
        emit WentFishing(_vault, amount);
    }

    function makeRequestUint256Array(uint256 size, address _caller) internal {
        bytes32 requestId = airnodeRrp.makeFullRequest(
            airnode,
            endpointIdUint256Array,
            address(this),
            sponsorWallet,
            address(this),
            this.fulfillUint256Array.selector,
            // Using Airnode ABI to encode the parameters
            abi.encode(bytes32("1u"), bytes32("size"), size)
        );
        expectingRequestWithIdToBeFulfilled[requestId] = true;
        //code the msg.sender to the requestId
        requestToSender[requestId] = _caller;
        emit RequestedUint256Array(requestId, size);
    }

    function fulfillUint256Array(
        bytes32 requestId,
        bytes calldata data
    ) external onlyAirnodeRrp {
        require(
            expectingRequestWithIdToBeFulfilled[requestId],
            "Request ID not known"
        );
        expectingRequestWithIdToBeFulfilled[requestId] = false;
        uint256[] memory qrngUint256Array = abi.decode(data, (uint256[]));
        for (uint i = 0; i < qrngUint256Array.length; i++) {
            qrngUint256Array[i] = (qrngUint256Array[i] % 5) + 1;
        }
        arrayOfRandoms = qrngUint256Array; // remove for demo (just public call of values)
        reelInPrize(requestToSender[requestId], qrngUint256Array);

        emit ReceivedUint256Array(requestId, qrngUint256Array);
    }
}

Mocking the Lil Pudgys and Rog Contracts

We need the addresses of both the lil Pudgys and the Rogs contracts to deploy our contract. Those only exist on mainnnet, but it would be costly to deploy this tutorial there. To deploy on testnet, we must mock the two contracts on a testnet.

In the repo created, a mock folder mimics the basics of the NFT contract. These were generated based on https://wizard.openzeppelin.com/. A big "thank you" to the holders that graciously allowed me to use their images for testnet purposes. (All pictures and metadata are stored on Arweave)

Here are the following code snippets for each contract:

The Lil Pudgys Mock Contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract LilPudgys is ERC721, ERC721Burnable, Ownable {
    using Counters for Counters.Counter;

    Counters.Counter private _tokenIdCounter;
    string private baseTokenUri = "https://dja2u2purosa2klfkjkz4ek5t326rgm6ndza56sibvmwjxes3oza.arweave.net/GkGqafSLpA0pZVJVnhFdnvXomZ5o8g76SA1ZZNyS27I/";


    constructor() ERC721("LilPudgys", "LP") {}

    function _baseURI() internal view override returns (string memory) {
        return baseTokenUri;
    }

    //return uri for certain token
    function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
        require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token");

        // Check if tokenID is even or odd and return the appropriate baseURI
        // Two images that alternate per mint
        return tokenId % 2 == 0 ? string(abi.encodePacked(baseTokenUri, "1.json")) : string(abi.encodePacked(baseTokenUri, "2.json"));
    }

    function safeMint() external {
        _tokenIdCounter.increment();
        uint256 tokenId = _tokenIdCounter.current();
        _safeMint(msg.sender, tokenId);
    }
}

The Rogs Mock Contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";


contract PudgyPresent is ERC721, ERC721Enumerable, Pausable, Ownable, ERC721Burnable {
    using Counters for Counters.Counter;

    Counters.Counter private _tokenIdCounter;
     string private baseTokenUri = "https://np4zj273suxharowsyxqhlz5ou2zptjytbh22eabsvdlxdga4g4q.arweave.net/a_mU6_uVLnBF1pYvA689dTWXzTiYT60QAZVGu4zA4bk/";

    constructor() ERC721("PudgyPresent", "PP") {}

    function _baseURI() internal view override returns (string memory) {
        return baseTokenUri;
    }

    function pause() external onlyOwner {
        _pause();
    }

    function unpause() external onlyOwner {
        _unpause();
    }

    function safeMint() external {
        _tokenIdCounter.increment();
        uint256 tokenId = _tokenIdCounter.current();
        _safeMint(msg.sender, tokenId);
    }

    function _beforeTokenTransfer(address from, address to, uint256 tokenId, uint256 batchSize)
        internal
        whenNotPaused
        override(ERC721, ERC721Enumerable)
    {
        super._beforeTokenTransfer(from, to, tokenId, batchSize);
    }

    //return uri for a certain token on a repeating cycle of 3
    function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
        require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token");

        // Check if tokenID modulo 3 and return the appropriate baseURI
        if (tokenId % 3 == 0) {
            return string(abi.encodePacked(baseTokenUri, "1.json"));
        } else if (tokenId % 3 == 1) {
            return string(abi.encodePacked(baseTokenUri, "2.json"));
        } else {
            return string(abi.encodePacked(baseTokenUri, "3.json"));
        }
    }

    // The following functions are overrides required by Solidity.

    function supportsInterface(bytes4 interfaceId)
        public
        View
        override(ERC721, ERC721Enumerable)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

D_D Newsletter CTA

Deploying the Mock Contracts

We can deploy this code using Remix or a framework like Hardhat or Foundry. For this tutorial, we will use Hardhat.

We can deploy these and mimic their use on any testnet.

In our scripts folder, we set out deployment to deploy our contracts. I've deployed the mock contracts and minted a few to claim, but I commented out the code. If we were to deploy on mainnet, we would get the already deployed contract (e.g., the actual contract) addresses and put them in arguments for our contract deployment.

Arguments we need:

  • Delegation registry: address of the registry depending on what chain is found here

  • Airnode Address: The airnode contract address depends on the deployment chain. Find the list here (for testnets, use Nodary)

  • Lil Pudgy contract address: Our Mock contract we deployed (lil)

  • Rogs contract address: The Mock contract we deployed

  • NFTURI: The token URI of the NFT metadata for our contract

import { ethers } from "hardhat";

async function main() {

  // Registry link: https://docs.delegate.xyz/delegatecash/technical-documentation/delegation-registry/contract-addresses
  const _delegationRegistry = "0x00000000000076A84feF008CDAbe6409d2FE638B"; // Delegation Registry Tesnet (Polygon)
  const _airnode = "0xa0AD79D995DdeeB18a14eAef56A549A04e3Aa1Bd"; // Nodary Airnode Testnet
  const lilpAddress = "0x34c42692c009Fe2E9d7e2b419Aa183d18CaDf2b9"; // Lil Pudgys
  const rogsAddress = "0x1EFcB45D3154369F39A4Cf2D5b6256DEf5c942C1"; // Pudgy Present 
  const NFTURI = "https://7q6a6lnjfy7bmfszdz6riql5avkbuznnubama3s27y2bvysktxaa.arweave.net/_DwPLakuPhYWWR59FEF9BVQaZa2gQMBuWv40GuJKncA/"; 

  // const LilP = await ethers.getContractFactory("LilPudgys");
  // const lilp = await LilP.deploy();
  // await lilp.deployed();
  // console.log("lilp deployed to:", lilp.address);  

  // const ROGS = await ethers.getContractFactory("PudgyPresent");
  // const rogs = await ROGS.deploy();
  // await rogs.deployed();
  // console.log("Rogs deployed to:", rogs.address);

  const Fish = await ethers.getContractFactory("TreasureHunt");
  const fish = await Fish.deploy(_delegationRegistry, _airnode, lilpAddress, rogsAddress, NFTURI);
  await fish.deployed();
  console.log("Fish deployed to:", fish.address);
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Once our script is in place, we can run the following command:

npx hardhat run scripts/deploy.ts --network mumbai

In this example, we are deploying to Mumbai Testnet.

Once successfully deployed, we will get a return of the contract address we deployed to.

Fish deployed to: 0x1F3B80BA2e8152E9798e38d8aD67EA5e14089dEd

Verifying the contract:

To verify the contract from the CLI, we will need to run the following command:

npx hardhat verify --network mumbai 0x1F3B80BA2e8152E9798e38d8aD67EA5e14089dEd 'constructor arguments'

Our contract has many arguments, but Hardhat only allows 1 or 2 of them to pass. To pass more, we need to make a separate file that we can call on to pass our arguments. We create a local file in our root directory called args.js and fill it in with the following constants that we set in our deployment scripts:

module.exports = [
  "0x00000000000076A84feF008CDAbe6409d2FE638B",
  "0xa0AD79D995DdeeB18a14eAef56A549A04e3Aa1Bd",
  "0x34c42692c009Fe2E9d7e2b419Aa183d18CaDf2b9",
  "0x1EFcB45D3154369F39A4Cf2D5b6256DEf5c942C1",
  "https://7q6a6lnjfy7bmfszdz6riql5avkbuznnubama3s27y2bvysktxaa.arweave.net/_DwPLakuPhYWWR59FEF9BVQaZa2gQMBuWv40GuJKncA/",
];

Now we can call on this file when trying to verify our contract as so:

npx hardhat verify --network mumbai --constructor-args args.js 0x1F3B80BA2e8152E9798e38d8aD67EA5e14089dEd

Our contract is now verified!

Our final few steps before we can start using our contract is setting up our QRNG parameters.

We need to provide

  • The Airnode Provider

  • The Endpoint Uint256 Array

  • The Sponsor Wallet (we will generate this)

The addresses for both the Airnode and Endpoint (array) can be found here (for testnets, use Nodary)

To generate the wallets, please refer to the video or link here.

In our CLI, we can copy the format filling out the addresses we get from the docs. The sponsor address is the contract we deployed, as that is the contract call that will need to be sponsored.

npx @api3/airnode-admin derive-sponsor-wallet-address ^
  --airnode-xpub xpub6CuDdF9zdWTRuGybJPuZUGnU4suZowMmgu15bjFZT2o6PUtk4Lo78KGJUGBobz3pPKRaN9sLxzj21CMe6StP3zUsd8tWEJPgZBesYBMY7Wo ^
  --airnode-address 0x6238772544f029ecaBfDED4300f13A3c4FE84E1D ^
  --sponsor-address 0x1F3B80BA2e8152E9798e38d8aD67EA5e14089dEd //<< Our deployed contract address

Once the script is done, it will return the generated sponsor wallet.

Sponsor wallet address: 0xB36A4152746d8F09d893107060975Df07950DC22

We can now complete our parameters setup on our contract.

We can go to the Write Contract section on the block explorer and fill in our parameters.

We send the transactions to complete our parameters.

Our final task is funding our sponsor wallet so it can pay for the gas for the response of the number generation to execute our contract.

We will send 0.1 MATIC to the sponsor contract for our testing purposes.

We are ready to play once we verify the sponsor contract has been funded!

We need to mint out a few Rogs and Lil Pudgys to be able to play. Go to our deployed Rogs and Lil Pudgys contract and write contract portion:

Mint out a couple of them, but keep in mind: the amount we mint affects the amount of fishing mints and the recovery time of the following fishing mint, so only do a few.

Then we can go fishing for prizes! (Choose)

We get the transaction details in the event:

Then we wait for a response a few blocks later:
It will emit an event(in our code) and get the notification of our ReceivedUint256Array with all the details (4 numbers, with token ID 1, 4, 4, 1)

Congrats! We have now successfully played the treasure hunt. To recap, we created a smart contract that interfaces with two existing deployed contracts, created a truly random return for our users to have a fair chance, and created the option for our users to choose how they can interact with our smart contract (asset filled wallet or the delegated wallet).

Part 2 - Building out the front end. (Coming soon)