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

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:
Basic Solidity Knowledge: Understand how smart contracts work.
Hardhat: A development environment for compiling and deploying contracts.
MetaMask: Configured for Rootstock Testnet (chain ID 31). Follow this guide to set it up.
Test RBTC: Get some from the Rootstock Testnet Faucet.
The source code for this tutorial can be found here https://github.com/michojekunle/nft-gated-event-mgmt-system/tree/master.
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
Multi-Event Support:
Organizers can create multiple events.
Each event is uniquely identified and has its own parameters.
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.
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.
Capacity Limits:
Each event has a maximum number of participants.
Once capacity is reached, further registrations are blocked.
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®isteredCount: 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 toEventdata.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 {
nonReentrantprevents 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 contractCaveParty.deployNFTGatedManager(): Deploys both the NFT and theNFTGatedEventManagercontract 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
owneris correctly set.Initial
eventIdCounteris0.
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
EventCreatedevent 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.

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:
| Function | Inputs | Outputs | Description |
| createEvent | string _eventName, uint256 _eventDate, address _nftRequired, uint256 _maxCapacity | - | Creates a new event (only the owner can create an event). |
| registerForEvent | uint256 _eventId | - | Registers a user for an event if they own the required NFT. |
| getEventDetails | uint256 _eventId | string, uint256, address, uint256, uint256, bool, bool, address[] | Returns event details (name, date, NFT, capacity, registered count, status, ERC721 support, attendees). |
| isUserRegistered | uint256 _eventId, address _user | bool | Checks if a user is registered for an event. |
| updateEventStatus | uint256 _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!





