Building Role-Based NFTs for Healthcare on the Stacks Blockchain.


In this tutorial, we’ll build a role-based NFT smart contract using Clarity, the smart contract language on the Stacks blockchain. In decentralized applications, managing permissions is critical. In a healthcare dApp, for instance, patients, doctors, and admin staff need different levels of access. Traditional systems use backend access control lists (ACLs), but on-chain apps need decentralized alternatives.
This is where Role NFTs come in, each role is represented as a non-fungible token (NFT). When someone owns a specific Role NFT, they gain access to the parts of the system tied to that role. It’s a decentralized, transparent way to manage identity and permissions.
🔍 Overview
We'll walk through creating a SIP-009 compliant NFT called RoleNFT, which represents roles like Patient
, Medical Staff
, Executive
, and more. This NFT contract:
Assigns a unique role to each token
Stores a URI to metadata (like an IPFS link)
Implements all the required SIP-009 functions
📦 Prerequisites
Clarinet (installed via Stacks CLI)
Basic familiarity with Clarity and smart contracts
Install Clarinet if you haven't:
npm install -g @hirosystems/clarinet
Create a new project:
clarinet new role-nft
cd role-nft
🧠 Step 1: Define Roles
In contracts/role-nft.clar
, start by defining roles as constants:
(define-constant ROLE_PATIENT u0)
(define-constant ROLE_MEDICAL_STAFF u1)
(define-constant ROLE_ADMIN_STAFF u2)
(define-constant ROLE_EXECUTIVE u3)
(define-constant ROLE_NON_MEDICAL_STAFF u4)
These constants act as enumerations that define the various roles within the system. Each role will be assigned to an NFT. By storing roles as uint
s, we keep the contract lightweight and easily comparable.
🔗 Step 2: Set Up SIP-009 NFT
SIP-009 is the standard interface for NFTs on Stacks. Implement metadata URI storage, ownership tracking, and SIP-009-required functions like get-owner
, get-token-uri
, and transfer
.
Define the NFT, URI, roles and id-counter map:
(define-non-fungible-token role-nft uint)
(define-map token-uri ((id uint)) ((uri (string-utf8 256))))
(define-map token-roles ((id uint)) ((role uint)))
(define-data-var token-id-counter uint u0)
define-non-fungible-token
lets us use the built-in Clarity NFT mechanics (ownership, minting, transfer).token-uri
stores off-chain metadata like role descriptions hosted on IPFS.token-roles
associates each token with a specific role.token-id-counter
helps generate unique token IDs.
🧬 Step 3: Minting Role NFTs
The mint-role
function assigns a role and URI to a new token.
(define-public (mint-role (recipient principal) (role uint) (uri (string-utf8 256)))
(if (not (is-some (get role {
u0: true, u1: true, u2: true, u3: true, u4: true
})))
(err u400)
(let ((id (+ (var-get token-id-counter) u1)))
(var-set token-id-counter id)
(map-set token-uri ((id id)) ((uri uri)))
(map-set token-roles ((id id)) ((role role)))
(nft-mint? role-nft id recipient)
)
)
)
What’s happening here?
The contract checks if the
role
exists in our predefined set. This ensures only valid roles can be assigned , preventing misuse or arbitrary role creation.var-get
and+ u1
increment the token ID.Metadata and role mappings are set before calling
nft-mint?
.
Why is this important?
Without the if
check, someone could mint a token with a non-existent or unintended role. This would undermine the access control logic of your app.
🔍 Step 4: Read Token Role
(define-read-only (get-role (id uint))
(default-to u999 (get role (map-get? token-roles ((id id)))))
)
Users and the app itself need a way to check what role an NFT carries. If the token isn’t found, we return u999
, a sentinel value indicating an invalid or missing role.
🧾 Step 5: SIP-009 Read Functions
(define-read-only (get-owner (id uint))
(nft-get-owner? role-nft id)
)
(define-read-only (get-token-uri (id uint))
(match (map-get? token-uri ((id id)))
uri-data (ok (get uri uri-data))
(err u404)
)
)
These SIP-009 functions are mandatory for wallet and dApp compatibility.
get-owner
lets apps verify who controls a roleget-token-uri
is used to render metadata (name, icon, description, etc.)
🔁 Step 6: Transfer NFTs
Roles can transfer from one principal to another. For example, when a new doctor replaces an old one, you’d transfer the ROLE_MEDICAL_STAFF
NFT:
(define-public (transfer (id uint) (sender principal) (recipient principal))
(nft-transfer? role-nft id sender recipient)
)
Under the hood, nft-transfer?
checks that sender
is the current owner of id
and, if so, moves ownership to recipient
. Any error bubbles up (e.g., err u404
if the token doesn’t exist, or err u403
if sender
isn’t the owner)
🧪 Step 7: Testing with Clarinet
Below are the essential tests you should write in tests/role-nft.test.ts
. Each it
block covers one scenario;
should return correct owner for token after mint
should return correct token URI after mint
should return correct role assigned to the token after mint
transfer should succeeds when sender is owner
should return error for non-existent token URI
transfer fails when sender is not owner
should return error for role of non-existent token
import { Cl } from "@stacks/transactions";
import { bool, principal, some, uint } from "@stacks/transactions/dist/cl";
import { describe, it, expect, beforeAll } from "vitest";
const accounts = simnet.getAccounts();
const admin = accounts.get("deployer")!;
const alice = accounts.get("wallet_1")!;
const bob = accounts.get("wallet_2")!;
describe("role-nft.clar", () => {
it("should return correct owner for token", () => {
simnet.callPublicFn(
"role-nft",
"mint-role",
[
principal(alice),
uint(1),
Cl.stringUtf8(
"ipfs://bafkreibnwna6ne5hqhsbs5wlyxxatkw2hm3sdqrng3by3t45qkof4q7e4a"
),
],
admin
);
const { result } = simnet.callReadOnlyFn(
"role-nft",
"get-owner",
[uint(1)],
admin
);
expect(result).toEqual(some(principal(alice)));
});
it("should return correct token URI", () => {
// Mint first
simnet.callPublicFn(
"role-nft",
"mint-role",
[
principal(alice),
uint(0),
Cl.stringUtf8(
"ipfs://bafkreibnwna6ne5hqhsbs5wlyxxatkw2hm3sdqrng3by3t45qkof4q7e4a"
),
],
admin
);
// Then query token URI
const { result } = simnet.callReadOnlyFn(
"role-nft",
"get-token-uri",
[uint(1)],
alice
);
expect(result).toBeOk(
Cl.stringUtf8(
"ipfs://bafkreibnwna6ne5hqhsbs5wlyxxatkw2hm3sdqrng3by3t45qkof4q7e4a"
)
);
});
it("should return correct role assigned to token", () => {
simnet.callPublicFn(
"role-nft",
"mint-role",
[
principal(alice),
uint(0),
Cl.stringUtf8(
"ipfs://bafkreibnwna6ne5hqhsbs5wlyxxatkw2hm3sdqrng3by3t45qkof4q7e4a"
),
],
admin
);
const { result } = simnet.callReadOnlyFn(
"role-nft",
"get-role",
[uint(1)],
alice
);
expect(result).toBeOk(uint(0));
});
it("should show balance of NFT for owner", () => {
const { result } = simnet.callReadOnlyFn(
"role-nft",
"get-balance",
[principal(alice)],
alice
);
expect(result).toBeOk(uint(0));
});
it("should transfer NFT between users", () => {
// Step 1: Mint the NFT to Alice
simnet.callPublicFn(
"role-nft",
"mint-role",
[
principal(alice),
uint(0), // ROLE_PATIENT
Cl.stringUtf8(
"ipfs://bafkreibnwna6ne5hqhsbs5wlyxxatkw2hm3sdqrng3by3t45qkof4q7e4a"
),
],
admin
);
// Step 2: Transfer it from Alice to Bob
const { result } = simnet.callPublicFn(
"role-nft",
"transfer",
[uint(1), principal(alice), principal(bob)],
alice
);
expect(result).toBeOk(bool(true));
});
it("should reflect new owner after transfer", () => {
simnet.callPublicFn(
"role-nft",
"mint-role",
[
principal(alice),
uint(0), // ROLE_PATIENT
Cl.stringUtf8(
"ipfs://bafkreibnwna6ne5hqhsbs5wlyxxatkw2hm3sdqrng3by3t45qkof4q7e4a"
),
],
admin
);
// Step 2: Transfer it from Alice to Bob
const { result: transferResult } = simnet.callPublicFn(
"role-nft",
"transfer",
[uint(1), principal(alice), principal(bob)],
alice
);
const { result: ownerResult } = simnet.callReadOnlyFn(
"role-nft",
"get-owner",
[uint(1)],
bob
);
expect(ownerResult).toEqual(some(principal(bob)));
});
it("should fail to mint NFT with invalid role", () => {
const { result } = simnet.callPublicFn(
"role-nft",
"mint-role",
[principal(bob), uint(999), Cl.stringUtf8("ipfs://invalid-role")],
admin
);
expect(result).toBeErr(uint(400));
});
it("should return error for non-existent token URI", () => {
const { result } = simnet.callReadOnlyFn(
"role-nft",
"get-token-uri",
[uint(999)],
admin
);
expect(result).toBeErr(uint(404));
});
it("should return error for role of non-existent token", () => {
const { result } = simnet.callReadOnlyFn(
"role-nft",
"get-role",
[uint(999)],
admin
);
expect(result).toBeErr(uint(404));
});
it("transfer should fail when not owner", () => {
simnet.callPublicFn(
"role-nft", "mint-role",
[principal(alice), uint(0), Cl.stringUtf8("ipfs://xfer2")],
admin
);
const { result } = simnet.callPublicFn(
"role-nft", "transfer",
[uint(1), principal(bob), principal(alice)],
bob
);
expect(result).toEqual(err(uint(403)));
});
});
Run tests using:
npm run test
💡 Other Use Cases for Role NFTs
Education – Teachers, students, admins, parents
DAO Governance – Voter roles, proposal creators, multisig signers
Gaming – Class or ability NFTs (e.g., wizard, warrior)
Each NFT acts like an on-chain key to permissioned resources.
Coming up Next
Create a frontend in Next.js to visualize role NFTs
Integrate IPFS for decentralized metadata
Use Clarity’s
define-trait
to enforce interfaces
🧾 Conclusion
We’ve built a complete role-based NFT system on Stacks using SIP-009. By separating identity and access into NFTs, we achieve transparent and flexible role management. This approach is useful for any system requiring decentralized access control.
Want the full code? Check the GitHub repo here: http://github.com/BiliqisO/MediVerse
🔗 Resources
Let me know in the comments if you want a frontend for this too!
Subscribe to my newsletter
Read articles from Biliqis Onikoyi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Biliqis Onikoyi
Biliqis Onikoyi
Web3 || FrontEnd Dev