Last active
June 8, 2025 14:51
-
-
Save kmjones1979/dfa8c6f1250bd9b4c9d298ff50e279b9 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// SPDX-License-Identifier: MIT | |
pragma solidity ^0.8.27; | |
import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; | |
import "@openzeppelin/contracts/access/Ownable.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_ZeroAddressNewMentor(); | |
error MentorNFT_SoulboundToken(); // New error for soulbound transfers | |
error MentorNFT_SupportDurationTooLong(); // Support duration exceeds maximum | |
// --- 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, Ownable, 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%) | |
uint256 public immutable minsPerWeek; | |
bool public immutable weeklyMeetings; | |
uint256 public immutable supportDurationWeeks; | |
mapping(uint256 => uint256) private _tokenPurchaseTimestamps; | |
mapping(uint256 => uint256) public escrowedAmount; | |
mapping(uint256 => uint256) public withdrawnByMentor; | |
mapping(address => uint256) public tokenIdByAddress; | |
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); | |
event MentorRoleAdded(address indexed newMentor, address indexed admin); | |
/** | |
* @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 _minsPerWeek Mentor defined minutes per week for this tier. | |
* @param _weeklyMeetings Does this tier grant weekly meeting access (e.g., Calendly)? | |
* @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, | |
uint256 _minsPerWeek, | |
bool _weeklyMeetings, | |
uint256 _supportDurationWeeks, | |
address _feeAddress, | |
uint256 _feeBasisPoints | |
) ERC721(_name, _symbol) Ownable() { | |
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 (_feeBasisPoints > 2500) revert MentorNFT_InvalidFeePercentage(); // Max 25% | |
maxSupply = _maxSupply; | |
mintPrice = _mintPrice; | |
mentor = _mentor; | |
minsPerWeek = _minsPerWeek; | |
weeklyMeetings = _weeklyMeetings; | |
supportDurationWeeks = _supportDurationWeeks; | |
feeAddress = _feeAddress; | |
feeBasisPoints = _feeBasisPoints; | |
_grantRole(ADMIN_ROLE, _feeAddress); | |
_grantRole(MENTOR_ROLE, _mentor); | |
_transferOwnership(_initialOwner); | |
} | |
/** | |
* @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 (tokenIdByAddress[msg.sender] != 0) revert MentorNFT_AddressAlreadyHoldsToken(); | |
uint256 tokenId = _tokenIdCounter; | |
if (tokenId >= maxSupply) revert MentorNFT_MaxSupplyReached(); | |
_tokenIdCounter++; | |
_safeMint(msg.sender, tokenId); | |
tokenIdByAddress[msg.sender] = tokenId; | |
_tokenPurchaseTimestamps[tokenId] = block.timestamp; | |
uint256 feeCut = (mintPrice * feeBasisPoints) / 10000; | |
if (feeAddress != address(0) && feeCut > 0) { | |
_safeTransferETH(feeAddress, feeCut); | |
} | |
uint256 escrow = (mintPrice * (10000 - feeBasisPoints)) / 10000; | |
escrowedAmount[tokenId] = escrow; | |
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) { | |
_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 tokenIdByAddress[msg.sender]; | |
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 Returns the tokenId for a given address. | |
* @param addr The address to get the tokenId for. | |
* @return The tokenId for the given address. | |
*/ | |
function getTokenIdByAddress(address addr) public view returns (uint256) { | |
return tokenIdByAddress[addr]; | |
} | |
/** | |
* @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 Adds a new mentor role to an address. | |
* @param newMentor The address to add the mentor role to. | |
*/ | |
function addMentorRole(address newMentor) public onlyRole(ADMIN_ROLE) { | |
if (newMentor == address(0)) revert MentorNFT_ZeroAddressNewMentor(); | |
_grantRole(MENTOR_ROLE, newMentor); | |
emit MentorRoleAdded(newMentor, msg.sender); | |
} | |
/** | |
* @notice Emergency withdrawal function to handle dust amounts. | |
* @dev Only callable by admin in case of precision losses or contract upgrades. | |
*/ | |
function emergencyWithdraw() public onlyRole(ADMIN_ROLE) nonReentrant { | |
uint256 contractBalance = address(this).balance; | |
if (contractBalance == 0) revert MentorNFT_NothingToWithdraw(); | |
_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