Skip to content

Instantly share code, notes, and snippets.

@kmjones1979
Created June 10, 2025 21:51
Show Gist options
  • Save kmjones1979/927ec37ca3bf5ea5593a79e00ddff9d1 to your computer and use it in GitHub Desktop.
Save kmjones1979/927ec37ca3bf5ea5593a79e00ddff9d1 to your computer and use it in GitHub Desktop.
Updated Version
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
// --- Custom Errors ---
error MentorNFT_ZeroAddressOwner();
error MentorNFT_ZeroAddressMentor();
error MentorNFT_ZeroAddressFee();
error MentorNFT_ZeroMaxSupply();
error MentorNFT_ZeroSupportDuration();
error MentorNFT_InvalidFeePercentage();
error MentorNFT_ContractCancelled();
error MentorNFT_IncorrectMintPrice();
error MentorNFT_AddressAlreadyHoldsToken();
error MentorNFT_MaxSupplyReached();
error MentorNFT_FeeTransferFailed();
error MentorNFT_RefundFailed();
error MentorNFT_NotTokenOwner();
error MentorNFT_AlreadyCancelled();
error MentorNFT_NothingToWithdraw();
error MentorNFT_WithdrawalFailed();
error MentorNFT_RefundFailedInBurn();
error MentorNFT_ZeroAddressNewFee();
error MentorNFT_InvalidNewFeePercentage();
error MentorNFT_SoulboundToken();
error MentorNFT_SupportDurationTooLong();
// --- End Custom Errors ---
/**
* @title MentorMenteeNFT (Soulbound)
* @notice A soulbound ERC721 token contract deployed by the MentorMenteeNFTFactory for token-gating mentorships.
* Each contract instance has a specific mentor, max supply, and mint price.
* Sends a percentage of the mint price to a configurable treasury address.
* Minting can be paused by the owner.
* Tracks purchase timestamps to enforce support duration.
* Tokens are soulbound and cannot be transferred except through burning.
*/
contract MentorNFT is ERC721, Pausable, AccessControl, ReentrancyGuard {
// --- Role Definitions ---
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant MENTOR_ROLE = keccak256("MENTOR_ROLE");
// --- State Variables ---
uint256 private _tokenIdCounter;
uint256 public immutable maxSupply;
uint256 public immutable mintPrice;
address public immutable mentor;
address public feeAddress;
uint256 public feeBasisPoints; // Fee in basis points (0-2500 = 0-25%)
bool public immutable weeklyMeetings;
uint256 public immutable minsPerWeek;
uint256 public immutable supportDurationWeeks;
mapping(uint256 => uint256) private _tokenPurchaseTimestamps;
mapping(uint256 => uint256) public escrowedAmount;
mapping(uint256 => uint256) public withdrawnByMentor;
bool public isCancelled;
// High precision multiplier for vesting calculations
uint256 private constant PRECISION_MULTIPLIER = 1e18;
event FeeAddressUpdated(address indexed newFeeAddress);
event ContractCancelled(address indexed mentor);
event FeeBasisPointsUpdated(uint256 newFeeBasisPoints);
event Minted(address indexed minter, uint256 indexed tokenId, uint256 timestamp);
event MentorWithdraw(address indexed mentor, uint256 amount, uint256 tokenId);
event TokenBurned(address indexed owner, uint256 indexed tokenId, uint256 refundToMentee, uint256 payoutToMentor);
event EmergencyWithdraw(address indexed admin, uint256 amount);
/**
* @param _name The name of the NFT collection.
* @param _symbol The symbol of the NFT collection.
* @param _maxSupply The maximum number of tokens that can be minted.
* @param _mintPrice The price required to mint one token (in wei).
* @param _mentor The address of the mentor deploying this contract.
* @param _initialOwner The initial owner of this contract (should be the mentor).
* @param _weeklyMeetings Does this tier grant weekly meeting access (e.g., Calendly)?
* @param _minsPerWeek Minutes per week commitment for this mentorship tier.
* @param _supportDurationWeeks How many weeks does the mentorship support last?
* @param _feeAddress The address to receive protocol fees.
* @param _feeBasisPoints Fee in basis points (0-2500 = 0-25%).
*/
constructor(
string memory _name,
string memory _symbol,
uint256 _maxSupply,
uint256 _mintPrice,
address _mentor,
address _initialOwner,
bool _weeklyMeetings,
uint256 _minsPerWeek,
uint256 _supportDurationWeeks,
address _feeAddress,
uint256 _feeBasisPoints
) ERC721(_name, _symbol) {
if (_initialOwner == address(0)) revert MentorNFT_ZeroAddressOwner();
if (_mentor == address(0)) revert MentorNFT_ZeroAddressMentor();
if (_feeAddress == address(0)) revert MentorNFT_ZeroAddressFee();
if (_maxSupply == 0) revert MentorNFT_ZeroMaxSupply();
if (_supportDurationWeeks == 0) revert MentorNFT_ZeroSupportDuration();
if (_supportDurationWeeks > 20) revert MentorNFT_SupportDurationTooLong(); // Max 20 weeks
if (_minsPerWeek == 0) revert MentorNFT_ZeroSupportDuration(); // Reuse error for simplicity
if (_feeBasisPoints > 2500) revert MentorNFT_InvalidFeePercentage(); // Max 25%
minsPerWeek = _minsPerWeek;
maxSupply = _maxSupply;
mintPrice = _mintPrice;
mentor = _mentor;
weeklyMeetings = _weeklyMeetings;
supportDurationWeeks = _supportDurationWeeks;
feeAddress = _feeAddress;
feeBasisPoints = _feeBasisPoints;
_grantRole(ADMIN_ROLE, _feeAddress);
_grantRole(MENTOR_ROLE, _mentor);
}
/**
* @notice Safe ETH transfer with flexible gas limit
*/
function _safeTransferETH(address to, uint256 amount) private {
if (amount == 0) return;
(bool success, ) = to.call{ value: amount }("");
if (!success) revert MentorNFT_FeeTransferFailed();
}
/**
* @notice Mints a new NFT to the caller, records purchase timestamp, and sends fee to the treasury address.
* @dev Requires payment equal to or greater than the mintPrice.
* Reverts if the max supply has been reached.
* Reverts if minting is paused.
*/
function mint() public payable whenNotPaused nonReentrant {
if (isCancelled) revert MentorNFT_ContractCancelled();
if (msg.value < mintPrice) revert MentorNFT_IncorrectMintPrice();
if (balanceOf(msg.sender) > 0) revert MentorNFT_AddressAlreadyHoldsToken();
uint256 tokenId = _tokenIdCounter;
if (tokenId >= maxSupply) revert MentorNFT_MaxSupplyReached();
_tokenIdCounter++;
uint256 feeCut = (mintPrice * feeBasisPoints) / 10000;
uint256 escrow = mintPrice - feeCut;
escrowedAmount[tokenId] = escrow;
// Send fee to fee address immediately
if (feeCut > 0) {
(bool success, ) = feeAddress.call{ value: feeCut }("");
require(success, "Fee transfer failed");
}
_safeMint(msg.sender, tokenId);
_tokenPurchaseTimestamps[tokenId] = block.timestamp;
uint256 refundAmount = msg.value - mintPrice;
if (refundAmount > 0) {
_safeTransferETH(msg.sender, refundAmount);
}
emit Minted(msg.sender, tokenId, block.timestamp);
}
// --- Soulbound Transfer Restrictions ---
/**
* @notice Soulbound: Transfers are not allowed
*/
function transferFrom(address, address, uint256) public pure override {
revert MentorNFT_SoulboundToken();
}
/**
* @notice Soulbound: Safe transfers are not allowed
*/
function safeTransferFrom(address, address, uint256) public pure override {
revert MentorNFT_SoulboundToken();
}
/**
* @notice Soulbound: Safe transfers with data are not allowed
*/
function safeTransferFrom(address, address, uint256, bytes memory) public pure override {
revert MentorNFT_SoulboundToken();
}
/**
* @notice Returns the purchase timestamp for a given token ID.
* @param tokenId The ID of the token.
* @return The Unix timestamp of when the token was purchased.
*/
function getPurchaseTimestamp(uint256 tokenId) public view returns (uint256) {
return _tokenPurchaseTimestamps[tokenId];
}
/**
* @notice Pauses the contract. Only callable by mentors.
* @dev This prevents minting via the `whenNotPaused` modifier.
*/
function pause() public onlyRole(MENTOR_ROLE) {
_pause();
}
/**
* @notice Unpauses the contract. Only callable by mentors.
* @dev This allows minting to resume.
*/
function unpause() public onlyRole(MENTOR_ROLE) {
if (isCancelled) revert MentorNFT_ContractCancelled();
_unpause();
}
/**
* @notice Allows the mentor to permanently cancel this specific NFT offering.
* @dev Sets isCancelled to true and pauses the contract. This is irreversible.
*/
function cancelContract() public onlyRole(MENTOR_ROLE) {
if (isCancelled) revert MentorNFT_AlreadyCancelled();
isCancelled = true;
if (!paused()) {
_pause();
}
emit ContractCancelled(mentor);
}
/**
* @dev See {IERC165-supportsInterface}.
*/
function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721, AccessControl) returns (bool) {
return super.supportsInterface(interfaceId);
}
/**
* @notice Returns the unlocked (vested) amount for a given tokenId.
* @dev Uses high precision arithmetic to avoid rounding errors.
*/
function unlockedAmount(uint256 tokenId) public view returns (uint256) {
uint256 start = _tokenPurchaseTimestamps[tokenId];
if (start == 0) return 0;
uint256 elapsed = block.timestamp - start;
uint256 totalDuration = supportDurationWeeks * 1 weeks;
if (elapsed >= totalDuration) return escrowedAmount[tokenId];
// Use high precision arithmetic to avoid rounding errors
uint256 highPrecisionResult = (escrowedAmount[tokenId] * elapsed * PRECISION_MULTIPLIER) / totalDuration;
return highPrecisionResult / PRECISION_MULTIPLIER;
}
/**
* @notice Allows the mentor to withdraw vested (unlocked) portions of escrowed mint fees.
* @param tokenId The tokenId to withdraw from.
*/
function mentorWithdraw(uint256 tokenId) public onlyRole(MENTOR_ROLE) nonReentrant {
uint256 unlocked = unlockedAmount(tokenId);
uint256 alreadyWithdrawn = withdrawnByMentor[tokenId];
uint256 withdrawable = unlocked > alreadyWithdrawn ? unlocked - alreadyWithdrawn : 0;
if (withdrawable == 0) revert MentorNFT_NothingToWithdraw();
// Use SafeMath equivalent (Solidity 0.8+ has built-in overflow protection)
withdrawnByMentor[tokenId] += withdrawable;
// Send to the caller (mentor)
_safeTransferETH(msg.sender, withdrawable);
emit MentorWithdraw(msg.sender, withdrawable, tokenId);
}
/**
* @notice Allows the NFT owner to burn their token and receive a refund of the unvested portion of the escrow.
* @param tokenId The tokenId to burn.
*/
function burn(uint256 tokenId) public nonReentrant {
if (ownerOf(tokenId) != msg.sender) revert MentorNFT_NotTokenOwner();
uint256 currentEscrow = escrowedAmount[tokenId];
uint256 alreadyWithdrawnByMentor = withdrawnByMentor[tokenId];
// Use the unlockedAmount function directly for consistency
uint256 unlockedFromEscrow = unlockedAmount(tokenId);
// 'vestedToMentor' is the total amount the mentor is entitled to from the escrow
uint256 vestedToMentor;
if (unlockedFromEscrow > alreadyWithdrawnByMentor) {
vestedToMentor = unlockedFromEscrow;
} else {
vestedToMentor = alreadyWithdrawnByMentor;
}
// Refund for the mentee is the unvested portion of the escrow
uint256 refundToMentee = 0;
if (currentEscrow > vestedToMentor) {
refundToMentee = currentEscrow - vestedToMentor;
}
// Amount mentor can still withdraw from the vested portion
uint256 payoutToMentor = 0;
if (vestedToMentor > alreadyWithdrawnByMentor) {
payoutToMentor = vestedToMentor - alreadyWithdrawnByMentor;
}
// Clean up state associated with this token
delete _tokenPurchaseTimestamps[tokenId];
delete escrowedAmount[tokenId];
delete withdrawnByMentor[tokenId];
_burn(tokenId); // Actual NFT burn
// Pay the mentor their remaining share
if (payoutToMentor > 0) {
_safeTransferETH(mentor, payoutToMentor);
}
// Refund the mentee
if (refundToMentee > 0) {
_safeTransferETH(msg.sender, refundToMentee);
}
emit TokenBurned(msg.sender, tokenId, refundToMentee, payoutToMentor);
}
/**
* @notice Sets the fee address.
* @param newFeeAddress The new fee address.
*/
function setFeeAddress(address newFeeAddress) public onlyRole(ADMIN_ROLE) {
if (newFeeAddress == address(0)) revert MentorNFT_ZeroAddressNewFee();
feeAddress = newFeeAddress;
emit FeeAddressUpdated(newFeeAddress);
}
/**
* @notice Sets the fee basis points.
* @param newFeeBasisPoints The new fee basis points.
*/
function setFeeBasisPoints(uint256 newFeeBasisPoints) public onlyRole(ADMIN_ROLE) {
if (newFeeBasisPoints > 2500) revert MentorNFT_InvalidNewFeePercentage();
feeBasisPoints = newFeeBasisPoints;
emit FeeBasisPointsUpdated(newFeeBasisPoints);
}
/**
* @notice Emergency withdrawal function to handle dust amounts.
* @dev Only callable by admin in case of precision losses or contract upgrades.
* Automatically cancels the contract after withdrawal to prevent further minting.
*/
function emergencyWithdraw() public onlyRole(ADMIN_ROLE) nonReentrant {
uint256 contractBalance = address(this).balance;
if (contractBalance == 0) revert MentorNFT_NothingToWithdraw();
// Cancel the contract first to prevent any new minting
if (!isCancelled) {
isCancelled = true;
if (!paused()) {
_pause();
}
emit ContractCancelled(mentor);
}
_safeTransferETH(msg.sender, contractBalance);
emit EmergencyWithdraw(msg.sender, contractBalance);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment