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

Biliqis OnikoyiBiliqis Onikoyi
7 min read

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 uints, 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 role

  • get-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

  1. Education – Teachers, students, admins, parents

  2. DAO Governance – Voter roles, proposal creators, multisig signers

  3. 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!

0
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