Skip to main content

Command Palette

Search for a command to run...

Building an NFT-Gated Event Management Contract on Rootstock: A Beginner’s Guide

Learn How to Create Exclusive, Secure Events with NFTs on Rootstock’s Bitcoin-Backed Blockchain

Published
20 min read
Building an NFT-Gated Event Management Contract on Rootstock: A Beginner’s Guide
M

I love crafting beautiful web experiences and writing efficient smart contracts. I'm currently exploring Zero-Knowledge proofs, as well as Functional, Systems, Concurrent, and Scripting Programming Languages (Rust, Erlang, and Python).

Hey there, blockchain builders! Today, we’re diving into something exciting: creating an NFT-gated event management contract on Rootstock, a Bitcoin sidechain that brings smart contract power to the Bitcoin ecosystem. If you’ve ever wanted to host an event where only people with a specific NFT can join, like an exclusive concert or a members-only webinar, this guide is for you. We’ll walk through everything step-by-step, from setting up your environment to writing and deploying the contract on Rootstock’s Testnet. Don’t worry if you’re new to this; I’ll explain each part in simple terms. Let’s get started!

What is an NFT-Gated Event Management Contract?

Imagine you’re hosting a special event, but only people with a unique digital collectible (an NFT) can attend. That’s what an NFT-gated event management contract does: it checks if someone owns a specific NFT before letting them register for your event. It’s like a digital bouncer, ensuring only the right people get in.

Why Build This on Rootstock?

Rootstock is a Bitcoin sidechain that lets you build smart contracts with the security of Bitcoin’s mining power. Here’s why it’s perfect for this project:

  • Bitcoin-Backed Security: Your contract is protected by Bitcoin’s massive hash rate (over 500 exahashes/second).

  • Low Fees: Transactions cost a fraction of Ethereum’s fees (often ~0.0001 RBTC).

  • EVM Compatibility: Use the same tools (Solidity, Hardhat) as Ethereum.

  • Real-World Impact: Create exclusive events or features in Rootstock’s growing $6.6 billion DeFi ecosystem (February 2025).

Whether you’re building for fun or profit, Rootstock makes it secure and affordable.

Prerequisites

To follow along, you’ll need:

Contract Design: What Does It Do?

The NFTGatedEventManager is designed to manage multiple gated events where access is granted only to holders of specific NFT collections.

✅ Key Features

  1. Multi-Event Support:

    • Organizers can create multiple events.

    • Each event is uniquely identified and has its own parameters.

  2. NFT Gating:

    • Each event is associated with a specific ERC-721 NFT collection.

    • Only users who own at least one NFT from the required collection can register.

  3. Access Control & Registration Logic:

    • Users can register only once per event.

    • Registration is denied if:

      • The event has reached its maximum capacity.

      • The event has already occurred.

      • The user doesn’t own the required NFT.

      • The user is already registered.

  4. Capacity Limits:

    • Each event has a maximum number of participants.

    • Once capacity is reached, further registrations are blocked.

  5. Event Lifecycle Management:

    • Events can be marked as inactive, disabling further registrations.

    • Timestamps are used to determine if an event has passed.

🧠 How It Works – In Simple Terms

  • Think of each event like a private party that requires a special invite — in this case, an NFT.

  • The contract checks if you hold that NFT invite, and if the party isn't full or over.

  • If you meet all conditions, you're registered and recorded on the blockchain.

Writing the Contract

First, let’s scaffold a new hardhat project by running these commands in your terminal

mkdir nft-gated-event-mgmt-system
cd nft-gated-event-mgmt-system
npx hardhat init

And yes(enter) all the way through, i.e., selecting a Typescript project, and yes to other options.

Then, let’s install two required dependencies for this project, @openzeppelin/contracts and dotenv run the command

npm i @openzeppelin/contracts --save-dev dotenv

Let’s build the contracts in Solidity. We’ll use OpenZeppelin’s IERC721 and IERC165 interfaces to interact with the NFT contract.

Now, to the contract in your contracts directory, you can delete the default Lock.sol contract and create a new contract NFTGatedEventManager.sol In your contracts directory. We’ll take it bit by bit. Let’s go

✅ Step 1: License and Version Declaration

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
  • SPDX-License-Identifier: Specifies the license for the contract. MIT is permissive and open-source.

  • pragma solidity ^0.8.28: Ensures the compiler version is compatible with the features and syntax used in the contract.

✅ Step 2: Import Dependencies

import {IERC721} from "@openzeppelin/contracts/interfaces/IERC721.sol";
import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol";
import '@openzeppelin/contracts/utils/ReentrancyGuard.sol';

We bring in three modules from OpenZeppelin:

  • IERC721: Interface for interacting with NFTs.

  • IERC165: Used to check if a contract supports a certain interface (in this case, ERC721).

  • ReentrancyGuard: Protects against reentrancy attacks (useful for public/external functions).

✅ Step 3: Define the Contract and State Variables

contract NFTGatedEventManager is ReentrancyGuard {

We're creating a contract that inherits protection from ReentrancyGuard.

    struct Event {
        string eventName;
        uint256 eventDate;
        address nftRequired;
        bool isActive;
        uint256 maxCapacity;
        uint256 registeredCount;
        bool supportsERC721;
        address[] attendees;
        mapping(address => bool) isRegistered; 
    }

Each event stores:

  • eventName: Display name.

  • eventDate: Timestamp of the event.

  • nftRequired: NFT address needed for entry.

  • isActive: If the event is live.

  • maxCapacity & registeredCount: Track and limit attendees.

  • supportsERC721: True if NFT is ERC-721.

  • attendees: List of registered addresses.

    mapping(uint256 => mapping (address => bool)) public isUserRegistered; 
    uint256 public eventIdCounter;
    mapping(uint256 => Event) public events;
    address public owner;
  • isUserRegistered: Tracks users who have registered for a specific event.

  • eventIdCounter: Tracks event IDs.

  • events: Stores all events, i.e., maps event IDs to Event data.

  • owner: Admin address that controls event creation.

✅ Step 4: Modifiers and Events

    modifier onlyOwner() {
        require(msg.sender == owner, "Only the contract owner can call this function.");
        _;
    }

This limits certain functions (like creating events) to the contract deployer.

    event EventCreated(
        uint256 eventId,
        string eventName,
        uint256 eventDate,
        address nftRequired,
        uint256 maxCapacity
    );
    event UserRegistered(uint256 eventId, address indexed user);
    event EventStatusUpdated(uint256 eventId, bool newStatus);

Events are emitted on major state changes (create, register, status toggle). These are useful for frontend apps and logs.

✅ Step 5: Constructor

constructor() {
    owner = msg.sender;
}
  • Sets the “deploying address” as the contract owner.

✅ Step 6: Create an Event

 function createEvent(string memory _eventName, uint256 _eventDate, address _nftRequired, uint256 _maxCapacity) public onlyOwner {

This function:

  • Only callable by owner.

  • Takes in event details.

  • Performs input validation:

    • The event must be in the future.

    • Max capacity must be greater than 0.

    • The provided NFT address must be a deployed contract.

    • That contract must implement the ERC721 standard.

💡 Validations:

require(
    _eventDate > block.timestamp,
    "Event date must be in the future."
);

require(_maxCapacity > 0, "Max capacity must be greater than zero.");

require(
    _nftRequired.code.length > 0,
    "Required NFT address is not a contract"
);

bool supportsERC721 = IERC165(_nftRequired).supportsInterface(
    type(IERC721).interfaceId
);

require(
    supportsERC721,
    "Required NFT Address is not an ERC721 contract"
);

💾 Create the Event:

Event storage newEvent = events[eventIdCounter];
newEvent.eventName = _eventName;
newEvent.eventDate = _eventDate;
newEvent.nftRequired = _nftRequired;
newEvent.maxCapacity = _maxCapacity;
newEvent.isActive = true;
newEvent.supportsERC721 = supportsERC721;

Stores event data in the events mapping.

emit EventCreated(...);
eventIdCounter++;

Emits an event and increments the counter.

✅ Step 7: Register for an Event

function registerForEvent(uint256 _eventId) external nonReentrant {
  • nonReentrant prevents reentrancy attacks.

  • The function allows any wallet to register if:

    • The event is active.

    • The event date has not passed.

    • Max capacity for the event is not exceeded.

    • The user isn't already registered.

    • The user owns the required NFT.

✅ Checks and Conditions:

Event storage currentEvent = events[_eventId];
require(currentEvent.isActive, "Event is not active.");
require(
    block.timestamp < currentEvent.eventDate,
    "Event registration has closed."
);
require(
    currentEvent.registeredCount < currentEvent.maxCapacity,
    "Event is fully booked."
);
require(
    !isUserRegistered(_eventId, msg.sender),
    "You are already registered for this event."
);
require(
    IERC721(currentEvent.nftRequired).balanceOf(msg.sender) > 0,
    "You do not own the required NFT."
);

✅ Step 8: Get Event Details

// Get event details by ID
    function getEventDetails(
        uint256 _eventId
    )
        external
        view
        returns (
            string memory,
            uint256,
            address,
            uint256,
            uint256,
            bool,
            bool,
            address[] memory
        )
    {
        Event storage currentEvent = events[_eventId];
        return (
            currentEvent.eventName,
            currentEvent.eventDate,
            currentEvent.nftRequired,
            currentEvent.maxCapacity,
            currentEvent.registeredCount,
            currentEvent.isActive,
            currentEvent.supportsERC721,
            currentEvent.attendees
        );
    }

Returns core details of an event to external users. This is a read-only(view) function.

✅ Step 9: Update Event Status

    function updateEventStatus(uint256 _eventId, bool _isActive) external onlyOwner {
        Event storage currentEvent = events[_eventId];
        currentEvent.isActive = _isActive;
        emit EventStatusUpdated(_eventId, _isActive);
    }

Allows the owner to toggle an event on or off.

✅ Step 10: Deactivate Expired Events (Through a keeper/cron job)

function deactivateExpiredEvents() external {
        for (uint256 i = 0; i < eventIdCounter; i++) {
            Event storage currentEvent = events[i];
            if (
                currentEvent.isActive &&
                block.timestamp > currentEvent.eventDate
            ) {
                currentEvent.isActive = false;
                emit EventStatusUpdated(i, false);
            }
        }
    }

Checks and deactivates all events that are expired

🧩 Full Contract (Put Together)

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

import {IERC721} from "@openzeppelin/contracts/interfaces/IERC721.sol";
import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract NFTGatedEventManager is ReentrancyGuard {
    struct Event {
        string eventName;
        uint256 eventDate;
        address nftRequired;
        bool isActive;
        uint256 maxCapacity;
        uint256 registeredCount;
        bool supportsERC721;
        address[] attendees;
    }

    mapping(uint256 => mapping (address => bool)) public isUserRegistered; // Tracks users who have registered

    uint256 public eventIdCounter;
    mapping(uint256 => Event) public events;
    address public owner;

    modifier onlyOwner() {
        require(
            msg.sender == owner,
            "Only the contract owner can call this function."
        );
        _;
    }

    event EventCreated(
        uint256 eventId,
        string eventName,
        uint256 eventDate,
        address nftRequired,
        uint256 maxCapacity
    );
    event UserRegistered(uint256 eventId, address indexed user);
    event EventStatusUpdated(uint256 eventId, bool newStatus);

    constructor() {
        owner = msg.sender; // Set the contract deployer as the owner
    }

    // Event creation: Only the owner can create an event
    function createEvent(
        string memory _eventName,
        uint256 _eventDate,
        address _nftRequired,
        uint256 _maxCapacity
    ) public onlyOwner {
        require(
            _eventDate > block.timestamp,
            "Event date must be in the future."
        );
        require(_maxCapacity > 0, "Max capacity must be greater than zero.");
        require(
            _nftRequired.code.length > 0,
            "Required NFT address is not a contract"
        );

        bool supportsERC721 = IERC165(_nftRequired).supportsInterface(
            type(IERC721).interfaceId
        );
        require(
            supportsERC721,
            "Required NFT Address is not an ERC721 contract"
        );

        Event storage newEvent = events[eventIdCounter];
        newEvent.eventName = _eventName;
        newEvent.eventDate = _eventDate;
        newEvent.nftRequired = _nftRequired;
        newEvent.maxCapacity = _maxCapacity;
        newEvent.isActive = true;
        newEvent.supportsERC721 = supportsERC721;

        emit EventCreated(
            eventIdCounter,
            _eventName,
            _eventDate,
            _nftRequired,
            _maxCapacity
        );

        eventIdCounter++; // Increment event ID counter for the next event
    }

    // Register for an event: Verifies NFT ownership
    function registerForEvent(uint256 _eventId) external nonReentrant {
        Event storage currentEvent = events[_eventId];
        require(currentEvent.isActive, "Event is not active.");
        require(
            block.timestamp < currentEvent.eventDate,
            "Event registration has closed."
        );
        require(
            currentEvent.registeredCount < currentEvent.maxCapacity,
            "Event is fully booked."
        );
        require(
            !isUserRegistered[_eventId][msg.sender],
            "You are already registered for this event."
        );
        require(
            IERC721(currentEvent.nftRequired).balanceOf(msg.sender) > 0,
            "You do not own the required NFT."
        );

        currentEvent.attendees.push(msg.sender);
        isUserRegistered[_eventId][msg.sender] = true;
        currentEvent.registeredCount++;

        emit UserRegistered(_eventId, msg.sender);
    }

    // Get event details by ID
    function getEventDetails(
        uint256 _eventId
    )
        external
        view
        returns (
            string memory,
            uint256,
            address,
            uint256,
            uint256,
            bool,
            bool,
            address[] memory
        )
    {
        Event storage currentEvent = events[_eventId];
        return (
            currentEvent.eventName,
            currentEvent.eventDate,
            currentEvent.nftRequired,
            currentEvent.maxCapacity,
            currentEvent.registeredCount,
            currentEvent.isActive,
            currentEvent.supportsERC721,
            currentEvent.attendees
        );
    }

    // Toggle event status (activate/deactivate)
    function updateEventStatus(
        uint256 _eventId,
        bool _isActive
    ) external onlyOwner {
        Event storage currentEvent = events[_eventId];
        currentEvent.isActive = _isActive;

        emit EventStatusUpdated(_eventId, _isActive);
    }

    function deactivateExpiredEvents() external {
        for (uint256 i = 0; i < eventIdCounter; i++) {
            Event storage currentEvent = events[i];
            if (
                currentEvent.isActive &&
                block.timestamp > currentEvent.eventDate
            ) {
                currentEvent.isActive = false;
                emit EventStatusUpdated(i, false);
            }
        }
    }
}

We’re going to write an interesting and simple NFT contract for testing. Create a new file in your contracts directory contracts/CavePartyNFT.sol

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

import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract CaveParty is ERC721, Ownable(msg.sender) {
    // events
    event Minted(uint256 tokenId);

    // state variables
    uint256 public totalMints = 0;
    uint256 public mintPrice = 0.0001 ether;
    uint256 public maxPerWallet = 1;
    string public assetMetadata =
        "https://gateway.pinata.cloud/ipfs/QmeXnyhrkEGfKzQRtusyWNFKcjyZxLcU1puRvxLkK2kTeS";

    // mappings
    mapping(address => uint256) public walletMints;

    // functions
    constructor() ERC721("CaveParty", "CPY") {}

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

    function tokenURI(
        uint256 tokenId
    ) public view virtual override returns (string memory) {
        return assetMetadata;
    }

    function safeMint(address to) internal {
        uint256 tokenId = totalMints;
        totalMints++;

        _safeMint(to, tokenId);
    }

    function mintToken() external payable {
        require(msg.value >= mintPrice, "0.0001 ether required to mint");
        require(
            walletMints[msg.sender] < maxPerWallet,
            "mints per wallet exceeded"
        );

        walletMints[msg.sender] += 1;
        safeMint(msg.sender);
    }

    function setMintPrice(uint256 _newPrice) external onlyOwner {
        mintPrice = _newPrice;
    }

    function getMyWalletMints() external view returns (uint256) {
        return walletMints[msg.sender];
    }

    function withdrawFunds() external onlyOwner {
        (bool sent, ) = owner().call{value: address(this).balance}("");
        require(sent, "withdrawal failed");
    }

    function updateMetadata(
        string memory _newAssetMetadata
    ) external onlyOwner {
        assetMetadata = _newAssetMetadata;
    }

    function getAssetMetadata()
        external
        view
        onlyOwner
        returns (string memory)
    {
        return assetMetadata;
    }
}

Testing the Contract

To ensure the contract works as expected, we’ll write some tests using Hardhat.

In your tests directory, create a new test file tests/nft-gated-event-manger.ts

import {
  time,
  loadFixture,
} from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { ethers } from "hardhat";
import { expect } from "chai";

What this does:

  • time: Helps manipulate and check blockchain time (e.g., simulate future timestamps).

  • loadFixture: Used to cache the result of a function (e.g., deployments) so tests run faster and stay isolated.

  • ethers: Hardhat’s version of Ethers.js to interact with the blockchain and contracts.

  • expect: From the Chai library, used to make assertions like .to.eq(...), .to.be.revertedWith(...).

Helper Functions

  async function deployNFT() {
    const NFT = await ethers.getContractFactory("CaveParty");
    const nft = await NFT.deploy();
    return { nft };
  }

  async function deployNFTGatedManager() {
    const [owner, addr1, addr2, addr3, addr4] = await ethers.getSigners();
    const { nft } = await deployNFT();
    const NFTGatedEventManager = await ethers.getContractFactory(
      "NFTGatedEventManager"
    );
    const nftGatedEventManager = await NFTGatedEventManager.deploy();
    return { nft, nftGatedEventManager, owner, addr1, addr2, addr3, addr4 };
  }
  • deployNFT(): Deploys the mock NFT contract CaveParty.

  • deployNFTGatedManager(): Deploys both the NFT and the NFTGatedEventManager contract and returns test accounts (owner, addr1, etc.).

Test Suite 1: describe("deployment", ...)

Purpose:

To verify if the NFTGatedEventManager contract deploys correctly and has expected initial values.

it("should deploy successfully", async function () {
  const { nftGatedEventManager, owner } = await loadFixture(deployNFTGatedManager);

  expect(await nftGatedEventManager.owner()).to.eq(owner);
  expect(await nftGatedEventManager.eventIdCounter()).to.eq(0);
});

Explanation:

  • Calls the deploy function once using loadFixture.

  • Checks:

    • Contract owner is correctly set.

    • Initial eventIdCounter is 0.

Test Suite 2: describe("createEvent", ...)

Purpose:

To test that events can be created, and invalid data is properly rejected.

  it("should create a new event", async function () {
      const { nft, nftGatedEventManager } = await loadFixture(
        deployNFTGatedManager
      );
      const eventName = "Lagos Day Party";
      const eventDate = (await time.latest()) + 3600;
      const nftRequired = await nft.getAddress();
      const maxCapacity = 5;
      expect(
        await nftGatedEventManager.createEvent(
          eventName,
          eventDate,
          nftRequired,
          maxCapacity
        )
      )
        .to.emit(nftGatedEventManager, "EventCreated")
        .withArgs(0, eventName, eventDate, nftRequired, maxCapacity);
  });

Explanation:

  • Creates an event with a valid future date and NFT address.

  • Verifies the EventCreated event is emitted with the right parameters.

The rest of the test suite looks like this

import { time, loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { ethers } from "hardhat";
import { expect } from "chai";

describe("NFTGatedEventManager", function () {
    async function deployNFT() {
        const NFT = await ethers.getContractFactory("CaveParty");
        const nft = await NFT.deploy();
        return { nft };
    }

    async function deployNFTGatedManager() {
        const [owner, addr1, addr2, addr3, addr4] = await ethers.getSigners();
        const { nft } = await deployNFT();
        const NFTGatedEventManager = await ethers.getContractFactory("NFTGatedEventManager");
        const nftGatedEventManager = await NFTGatedEventManager.deploy();
        return { nft, nftGatedEventManager, owner, addr1, addr2, addr3, addr4 };
    }

    describe("deployment", function () {
        it("should deploy successfully", async function () {
            const { nftGatedEventManager, owner } = await loadFixture(deployNFTGatedManager);
            expect(await nftGatedEventManager.owner()).to.eq(owner);
            expect(await nftGatedEventManager.eventIdCounter()).to.eq(0);
        });
    });

    describe("createEvent", function () {
        it("should create a new event", async function () {
            const { nft, nftGatedEventManager } = await loadFixture(deployNFTGatedManager);
            const eventName = "Lagos Day Party";
            const eventDate = (await time.latest()) + 3600;
            const nftRequired = await nft.getAddress();
            const maxCapacity = 5;
            expect(
                await nftGatedEventManager.createEvent(eventName, eventDate, nftRequired, maxCapacity)
            )
                .to.emit(nftGatedEventManager, "EventCreated")
                .withArgs(0, eventName, eventDate, nftRequired, maxCapacity);
        });

        it("should revert with error if date is not in future", async function () {
            const { nft, nftGatedEventManager } = await loadFixture(deployNFTGatedManager);
            const eventName = "Lagos Day Party";
            const eventDate = await time.latest();
            const nftRequired = await nft.getAddress();
            const maxCapacity = 5;
            expect(
                nftGatedEventManager.createEvent(eventName, eventDate, nftRequired, maxCapacity)
            ).to.be.revertedWith("Event date must be in the future.");
        });

        it("should revert with error if max-capacity is less than 1", async function () {
            const { nft, nftGatedEventManager } = await loadFixture(deployNFTGatedManager);
            const eventName = "Lagos Day Party";
            const eventDate = await time.increase(10e10);
            const nftRequired = await nft.getAddress();
            const maxCapacity = 0;
            expect(
                nftGatedEventManager.createEvent(eventName, eventDate, nftRequired, maxCapacity)
            ).to.be.revertedWith("Max capacity must be greater than zero.");
        });

        it("should revert if token address passed is not a contract", async function () {
            const { nftGatedEventManager } = await loadFixture(deployNFTGatedManager);
            const eventName = "Lagos Day Party";
            const eventDate = (await time.latest()) + 3600;
            const nftRequired = ethers.ZeroAddress;
            const maxCapacity = 5;
            expect(
                nftGatedEventManager.createEvent(eventName, eventDate, nftRequired, maxCapacity)
            ).to.be.revertedWith("Required NFT address is not a contract");
        });

        it("should revert if token address passed is not an ERC721 contract", async function () {
            const { nftGatedEventManager } = await loadFixture(deployNFTGatedManager);
            const eventName = "Lagos Day Party";
            const eventDate = (await time.latest()) + 3600;
            const nftRequired = await nftGatedEventManager.getAddress();
            const maxCapacity = 5;
            expect(
                nftGatedEventManager.createEvent(eventName, eventDate, nftRequired, maxCapacity)
            ).to.be.revertedWith("Required NFT Address is not an ERC721 contract");
        });
    });

    describe("registerForEvent", function () {
        it("should revert if event to register for is not currently active", async function () {
            const { nftGatedEventManager } = await loadFixture(deployNFTGatedManager);
            expect(nftGatedEventManager.registerForEvent(1)).to.be.revertedWith("Event is not active.");
        });

        it("should revert if event time has passed", async function () {
            const { nftGatedEventManager, nft } = await loadFixture(deployNFTGatedManager);
            const eventName = "Lagos Day Party";
            const eventDate = (await time.latest()) + 3600;
            const nftRequired = await nft.getAddress();
            const maxCapacity = 5;
            await nftGatedEventManager.createEvent(eventName, eventDate, nftRequired, maxCapacity);
            await time.increase(5000);
            expect(nftGatedEventManager.registerForEvent(0)).to.be.revertedWith("Event registration has closed.");
        });

        it("should revert if event status is inactive", async function () {
            const { nftGatedEventManager, nft } = await loadFixture(deployNFTGatedManager);
            const eventName = "Lagos Day Party";
            const eventDate = (await time.latest()) + 3600;
            const nftRequired = await nft.getAddress();
            const maxCapacity = 5;
            await nftGatedEventManager.createEvent(eventName, eventDate, nftRequired, maxCapacity);
            expect(await nftGatedEventManager.updateEventStatus(0, false))
                .to.emit(nftGatedEventManager, "EventStatusUpdated")
                .withArgs(0, false);
            expect(nftGatedEventManager.registerForEvent(0)).to.be.revertedWith("Event is not active.");
        });

        it("should revert if maxCapacity for event is reached", async function () {
            const { nftGatedEventManager, addr1, addr2, nft } = await loadFixture(deployNFTGatedManager);
            const eventName = "Lagos Day Party";
            const eventDate = (await time.latest()) + 3600;
            const nftRequired = await nft.getAddress();
            const maxCapacity = 2;
            await nftGatedEventManager.createEvent(eventName, eventDate, nftRequired, maxCapacity);
            await nft.mintToken({ value: ethers.parseEther("0.0001") });
            await nft.connect(addr1).mintToken({ value: ethers.parseEther("0.0001") });
            await nft.connect(addr2).mintToken({ value: ethers.parseEther("0.0001") });
            await nftGatedEventManager.registerForEvent(0);
            await nftGatedEventManager.connect(addr1).registerForEvent(0);
            expect(nftGatedEventManager.connect(addr2).registerForEvent(0)).to.be.revertedWith("Event is fully booked.");
        });

        it("should revert if a user tries to register twice", async function () {
            const { nftGatedEventManager, nft } = await loadFixture(deployNFTGatedManager);
            const eventName = "Lagos Day Party";
            const eventDate = (await time.latest()) + 3600;
            const nftRequired = await nft.getAddress();
            const maxCapacity = 5;
            await nftGatedEventManager.createEvent(eventName, eventDate, nftRequired, maxCapacity);
            await nft.mintToken({ value: ethers.parseEther("0.0001") });
            await nftGatedEventManager.registerForEvent(0);
            expect(nftGatedEventManager.registerForEvent(0)).to.be.revertedWith("You are already registered for this event.");
        });

        it("should revert if a user doesn't have the required NFT", async function () {
            const { nftGatedEventManager, nft } = await loadFixture(deployNFTGatedManager);
            const eventName = "Lagos Day Party";
            const eventDate = (await time.latest()) + 3600;
            const nftRequired = await nft.getAddress();
            const maxCapacity = 5;
            await nftGatedEventManager.createEvent(eventName, eventDate, nftRequired, maxCapacity);
            expect(nftGatedEventManager.registerForEvent(0)).to.be.revertedWith("You do not own the required NFT.");
        });

        it("should register users for event and verify registration", async function () {
            const { nftGatedEventManager, nft, owner, addr1, addr2 } = await loadFixture(deployNFTGatedManager);
            const eventName = "Lagos Day Party";
            const eventDate = (await time.latest()) + 3600;
            const nftRequired = await nft.getAddress();
            const maxCapacity = 5;
            await nftGatedEventManager.createEvent(eventName, eventDate, nftRequired, maxCapacity);
            await nft.mintToken({ value: ethers.parseEther("0.0001") });
            await nft.connect(addr1).mintToken({ value: ethers.parseEther("0.0001") });
            await nft.connect(addr2).mintToken({ value: ethers.parseEther("0.0001") });
            expect(await nftGatedEventManager.registerForEvent(0))
                .to.emit(nftGatedEventManager, "UserRegistered")
                .withArgs(0, owner);
            expect(await nftGatedEventManager.connect(addr1).registerForEvent(0))
                .to.emit(nftGatedEventManager, "UserRegistered")
                .withArgs(0, addr1);
            expect(await nftGatedEventManager.connect(addr2).registerForEvent(0))
                .to.emit(nftGatedEventManager, "UserRegistered")
                .withArgs(0, addr2);
            const event1 = await nftGatedEventManager.getEventDetails(0);
            const registerCountForEvent1 = event1[4];
            expect(registerCountForEvent1).to.equal(3);
            expect(await nftGatedEventManager.isUserRegistered(0, owner)).to.equal(true);
            expect(await nftGatedEventManager.isUserRegistered(0, addr1)).to.equal(true);
            expect(await nftGatedEventManager.isUserRegistered(0, addr2)).to.equal(true);
        });
    });

    describe("check if user is registered", function () {
        it("should check if a user is registered", async function () {
            const { nftGatedEventManager, nft, owner } = await loadFixture(deployNFTGatedManager);
            const eventName = "Lagos Day Party";
            const eventDate = (await time.latest()) + 3600;
            const nftRequired = await nft.getAddress();
            const maxCapacity = 5;
            await nftGatedEventManager.createEvent(eventName, eventDate, nftRequired, maxCapacity);
            await nft.mintToken({ value: ethers.parseEther("0.0001") });
            expect(await nftGatedEventManager.registerForEvent(0))
                .to.emit(nftGatedEventManager, "UserRegistered")
                .withArgs(0, owner);
            const userRegistered = await nftGatedEventManager.isUserRegistered(0, owner);
            expect(userRegistered).to.equal(true);
        });
    });
});

The tests above validate the functionality of the NFTGatedEventManager smart contract.

Writing tests for a smart contract involves understanding the smart contract and thinking about edge cases. With a better understanding, a smart contract developer can write better and safer tests. Writing tests for your contracts as a smart contract developer cannot be overemphasized. For me, I see it as crucial to every contract you’re going to be writing.

To test run the command npx hardhat test

Deploying to Rootstock

Now our contracts are ready, let’s deploy them to Rootstock’s Testnet.

Step 1: Configure Hardhat for Rootstock

In your hardhat.config.ts, add the Rootstock Testnet configuration:

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import * as dotenv from "dotenv";
dotenv.config();

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
  solidity: "0.8.28",
  networks: {
    // for testnet
    rootstock: {
      url: process.env.ROOTSTOCK_TESTNET_RPC_URL!,
      accounts: [process.env.WALLET_KEY!],
    },
  },
  etherscan: {
    // Use "123" as a placeholder, because Blockscout doesn't need a real API key, and Hardhat will complain if this property isn't set.
    apiKey: {
      rootstock: "123",
    },
    customChains: [
      {
        network: "rootstock",
        chainId: 31,
        urls: {
          apiURL: "https://rootstock-testnet.blockscout.com/api/",
          browserURL: "https://rootstock-testnet.blockscout.com/",
        },
      },
    ],
  },
  sourcify: {
    enabled: false,
  },
};

And in your .env file in the root directory of your hardhat project

WALLET_KEY=<your-wallet-key>
ROOTSTOCK_TESTNET_RPC_URL=<your-rootstock-testnet-rpc-url>

You can get your testnet RPC URL from Alchemy Dashboard, ensure you’ve selected testnet.

Alchemy dashboard to get your rootstock Testnet RPC URL

Step 2: Write the ignition deployment modules

Create a new file in ignition/modules/nft-gated-event-manager.ts:

import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";

const NFTGatedEventManagerModule = buildModule("NFTGatedEventManagerModule", (m) => {

  const NFTGatedEventManager = m.contract("NFTGatedEventManager");

  return { NFTGatedEventManager };
});

export default NFTGatedEventManagerModule;

Step 3: Deploy the Contract

Run the deployment script:

npx hardhat ignition deploy ./ignition/modules/nft-gated-event-manager.ts --network rootstock

This will deploy your contract to the Rootstock Testnet.

Quick Reference: ABI and Frontend Script

ABI Quick Reference

Below is a simplified ABI table for the key functions of the NFTGatedEventManager contract, useful for frontend integration:

FunctionInputsOutputsDescription
createEventstring _eventName, uint256 _eventDate, address _nftRequired, uint256 _maxCapacity-Creates a new event (only the owner can create an event).
registerForEventuint256 _eventId-Registers a user for an event if they own the required NFT.
getEventDetailsuint256 _eventIdstring, uint256, address, uint256, uint256, bool, bool, address[]Returns event details (name, date, NFT, capacity, registered count, status, ERC721 support, attendees).
isUserRegistereduint256 _eventId, address _userboolChecks if a user is registered for an event.
updateEventStatusuint256 _eventId, bool _isActive-Toggles event active status (owner only).
deactivateExpiredEvents--Deactivates all events past their event date.

A Minimal Frontend Script

Below is a JavaScript script using Ethers.js to interact with getEventDetails and registerForEvent. Assumes MetaMask is connected to Rootstock Testnet.

const ethers = require("ethers");

const contractAddress = "YOUR_CONTRACT_ADDRESS"; // Replace with deployed contract address
const abi = [
    "function getEventDetails(uint256 _eventId) external view returns (string memory, uint256, address, uint256, uint256, bool, bool, address[])",
    "function registerForEvent(uint256 _eventId) external",
    "function isUserRegistered(uint256 _eventId, address _user) external view returns (bool)"
];

async function init() {
    if (typeof window.ethereum !== "undefined") {
        const provider = new ethers.BrowserProvider(window.ethereum);
        const signer = await provider.getSigner();
        const contract = new ethers.Contract(contractAddress, abi, signer);
        return { provider, signer, contract };
    } else {
        console.error("Please install MetaMask!");
        return null;
    }
}

async function getEventDetails(eventId) {
    const { contract } = await init();
    try {
        const details = await contract.getEventDetails(eventId);
        console.log("Event Details:", {
            name: details[0],
            date: new Date(Number(details[1]) * 1000).toLocaleString(),
            nftRequired: details[2],
            maxCapacity: details[3].toString(),
            registeredCount: details[4].toString(),
            isActive: details[5],
            supportsERC721: details[6],
            attendees: details[7]
        });
    } catch (error) {
        console.error("Error fetching event details:", error);
    }
}

async function registerForEvent(eventId) {
    const { contract, signer } = await init();
    try {
        const isRegistered = await contract.isUserRegistered(eventId, await signer.getAddress());
        if (isRegistered) {
            console.log("You are already registered for this event.");
            return;
        }
        const tx = await contract.registerForEvent(eventId);
        await tx.wait();
        console.log("Successfully registered for event:", eventId);
    } catch (error) {
        console.error("Error registering for event:", error);
    }
}

// Example usage
getEventDetails(0); // Fetch details for event ID 0
registerForEvent(0); // Register for event ID 0

Usage Notes:

  • Replace YOUR_CONTRACT_ADDRESS with the deployed NFTGatedEventManager contract address.

  • Ensure MetaMask is connected to Rootstock Testnet (chain ID 31).

  • The script checks if the user is already registered before attempting to register again.

  • Use Ethers version 6.x for compatibility with modern browsers.

The source code for this tutorial can be found here https://github.com/michojekunle/nft-gated-event-mgmt-system/tree/master.

Conclusion

You’ve now built an NFT-gated event management contract on Rootstock! This contract lets you create exclusive events where only NFT holders can register, all while enjoying Rootstock’s Bitcoin-backed security and low fees. Whether you’re hosting a concert, webinar, or community meetup, this is a powerful way to engage your audience.

Want to take it further? Check out the Rootstock Developer Portal for more tutorials, or join the Rootstock Discord to connect with other developers. Happy coding!