Solidity implementation for Polygon deployment
This contract is production-ready but should only be deployed to Polygon testnet by students. Mainnet deployment requires adult supervision, security audits, and proper gas wallet management.
Granted to contract deployer. Can revoke BlockWards and manage roles.
Granted to backend gas wallet. Can mint BlockWards to students.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
/**
* @title BlockWardSoulbound
* @dev Soulbound NFT for student achievements on Polygon
* Non-transferable tokens that permanently record student accomplishments
*/
contract BlockWardSoulbound is ERC721, ERC721URIStorage, AccessControl {
bytes32 public constant ISSUER_ROLE = keccak256("ISSUER_ROLE");
uint256 private _nextTokenId;
struct WardData {
address student; // Student's wallet address
bytes32 schoolId; // School identifier
string title; // Achievement title
string description; // Achievement description
string category; // Category: academic, sports, arts, etc.
uint64 issuedAt; // Timestamp of issuance
bool revoked; // Revocation status
}
// Mapping from token ID to achievement data
mapping(uint256 => WardData) public wardData;
// Events
event BlockWardIssued(
address indexed student,
uint256 indexed tokenId,
bytes32 indexed schoolId,
string category,
uint64 issuedAt
);
event BlockWardRevoked(
uint256 indexed tokenId,
address indexed admin,
uint64 revokedAt
);
/**
* @dev Constructor sets up roles and initializes token counter
* @param issuer Address of the backend wallet that will mint tokens
*/
constructor(address issuer) ERC721("BlockWard", "BWARD") {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(ISSUER_ROLE, issuer);
_nextTokenId = 1;
}
/**
* @dev Issue a new BlockWard to a student
* @param student The student's wallet address
* @param schoolId The school's unique identifier
* @param title Achievement title
* @param description Achievement description
* @param category Achievement category
* @param tokenURI_ Metadata URI (typically IPFS)
*/
function issueBlockWard(
address student,
bytes32 schoolId,
string calldata title,
string calldata description,
string calldata category,
string calldata tokenURI_
) external onlyRole(ISSUER_ROLE) {
require(student != address(0), "Invalid student address");
uint256 tokenId = _nextTokenId++;
_safeMint(student, tokenId);
_setTokenURI(tokenId, tokenURI_);
wardData[tokenId] = WardData({
student: student,
schoolId: schoolId,
title: title,
description: description,
category: category,
issuedAt: uint64(block.timestamp),
revoked: false
});
emit BlockWardIssued(
student,
tokenId,
schoolId,
category,
uint64(block.timestamp)
);
}
/**
* @dev Revoke a BlockWard (admin only)
* @param tokenId The token ID to revoke
*/
function revokeBlockWard(uint256 tokenId)
external
onlyRole(DEFAULT_ADMIN_ROLE)
{
require(_ownerOf(tokenId) != address(0), "Token does not exist");
wardData[tokenId].revoked = true;
_burn(tokenId);
emit BlockWardRevoked(
tokenId,
msg.sender,
uint64(block.timestamp)
);
}
/**
* @dev Get all BlockWards for a student
* @param student The student's address
* @return Array of token IDs owned by the student
*/
function getStudentBlockWards(address student)
external
view
returns (uint256[] memory)
{
uint256 balance = balanceOf(student);
uint256[] memory tokens = new uint256[](balance);
uint256 index = 0;
for (uint256 tokenId = 1; tokenId < _nextTokenId; tokenId++) {
if (_ownerOf(tokenId) == student) {
tokens[index] = tokenId;
index++;
}
}
return tokens;
}
// --- SOULBOUND: Disable transfers and approvals ---
/**
* @dev Override _update to prevent transfers (soulbound)
* Allows minting (from address(0)) and burning (to address(0)) only
*/
function _update(
address to,
uint256 tokenId,
address auth
) internal override(ERC721) returns (address) {
address from = _ownerOf(tokenId);
// Allow minting and burning, block transfers
if (from != address(0) && to != address(0)) {
revert("Soulbound: transfers disabled");
}
return super._update(to, tokenId, auth);
}
/**
* @dev Disable approve function for soulbound tokens
*/
function approve(address, uint256) public pure override {
revert("Soulbound: approvals disabled");
}
/**
* @dev Disable setApprovalForAll for soulbound tokens
*/
function setApprovalForAll(address, bool) public pure override {
revert("Soulbound: approvals disabled");
}
// --- Required overrides ---
function _burn(uint256 tokenId)
internal
override(ERC721, ERC721URIStorage)
{
super._burn(tokenId);
}
function tokenURI(uint256 tokenId)
public
view
override(ERC721, ERC721URIStorage)
returns (string memory)
{
return super.tokenURI(tokenId);
}
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, ERC721URIStorage, AccessControl)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}issueBlockWard()Called by backend (ISSUER_ROLE) to mint a new achievement NFT to a student. Stores all metadata on-chain and emits an event for indexing.
revokeBlockWard()Admin-only function to burn a token if an achievement needs to be revoked. Sets the revoked flag and permanently removes the token.
getStudentBlockWards()View function that returns all token IDs owned by a specific student. Useful for displaying a student's achievement collection.
_update()Internal override that enforces soulbound behavior. Blocks all transfers between addresses while still allowing minting and burning.
Non-transferable: Once minted, BlockWards cannot be transferred, sold, or given away. They are permanently bound to the student's wallet address, ensuring achievements remain authentic and cannot be traded.
npm install @openzeppelin/contractsissuer parameternpx hardhat run scripts/deploy.js --network mumbainpx hardhat verify --network mumbai [ADDRESS] [ISSUER]{
"dependencies": {
"@openzeppelin/contracts": "^5.0.0"
},
"devDependencies": {
"hardhat": "^2.19.0",
"@nomicfoundation/hardhat-toolbox": "^4.0.0"
}
}