From fd4d5f59d048bd65ebaf8e1b736af6a47b0a7822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Fri, 2 Aug 2024 12:29:23 +0200 Subject: [PATCH] Graduation NFT (#13) --- .../hardhat/contracts/BatchGraduationNFT.sol | 97 ++++ packages/hardhat/contracts/BatchRegistry.sol | 19 +- .../hardhat/deploy/00_deploy_your_contract.ts | 12 +- .../nextjs/contracts/externalContracts.ts | 458 ++++++++++++++++++ 4 files changed, 582 insertions(+), 4 deletions(-) create mode 100644 packages/hardhat/contracts/BatchGraduationNFT.sol diff --git a/packages/hardhat/contracts/BatchGraduationNFT.sol b/packages/hardhat/contracts/BatchGraduationNFT.sol new file mode 100644 index 0000000..c349c6a --- /dev/null +++ b/packages/hardhat/contracts/BatchGraduationNFT.sol @@ -0,0 +1,97 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.0 <0.9.0; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/utils/Counters.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; +import "@openzeppelin/contracts/utils/Base64.sol"; + +interface IBatchRegistry { + function BATCH_NUMBER() external view returns (uint16); +} + +interface IGraduateNFTMetadata { + function getName() external view returns (string memory); + function getColor() external view returns (uint8, uint8, uint8); +} + +contract BatchGraduationNFT is ERC721 { + IBatchRegistry public batchRegistry; + + using Counters for Counters.Counter; + Counters.Counter private _tokenIds; + + mapping(address => address) public yourGraduationContractAddress; + mapping(uint256 => address) public tokenToMetadataContract; + + event MetadataSet(address builder, address metadataContract); + + // Errors + error NoMetadataSet(); + error UnauthorizedCaller(); + + constructor(address _batchRegistry) ERC721("BatchGraduate", "BGRAD") { + batchRegistry = IBatchRegistry(_batchRegistry); + } + + modifier callerIsBatchRegistry() { + if (msg.sender != address(batchRegistry)) revert UnauthorizedCaller(); + _; + } + + function setMetadataContract(address metadataContract) public { + // Verify that the contract has the required methods + IGraduateNFTMetadata(metadataContract).getName(); + IGraduateNFTMetadata(metadataContract).getColor(); + + yourGraduationContractAddress[msg.sender] = metadataContract; + emit MetadataSet(msg.sender, metadataContract); + } + + function mint(address builder) public callerIsBatchRegistry returns (uint256) { + if (yourGraduationContractAddress[builder] == address(0)) revert NoMetadataSet(); + + _tokenIds.increment(); + uint256 newTokenId = _tokenIds.current(); + _safeMint(builder, newTokenId); + + tokenToMetadataContract[newTokenId] = yourGraduationContractAddress[builder]; + return newTokenId; + } + + function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { + require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token"); + + address metadataContract = tokenToMetadataContract[tokenId]; + IGraduateNFTMetadata metadata = IGraduateNFTMetadata(metadataContract); + + string memory name = metadata.getName(); + (uint8 r, uint8 g, uint8 b) = metadata.getColor(); + + string memory svg = generateSVG(name, r, g, b); + string memory json = Base64.encode( + bytes(string( + abi.encodePacked( + '{"name": "', name, ' Graduate", ', + '"description": "Batch', Strings.toString(batchRegistry.BATCH_NUMBER()), ' Graduation NFT", ', + '"image": "data:image/svg+xml;base64,', Base64.encode(bytes(svg)), '"}' + ) + )) + ); + + return string(abi.encodePacked("data:application/json;base64,", json)); + } + + function generateSVG(string memory name, uint8 r, uint8 g, uint8 b) internal view returns (string memory) { + return string(abi.encodePacked( + '', + '', + '', + 'Batch ', Strings.toString(batchRegistry.BATCH_NUMBER()), '', + 'graduate', + '', name ,'', + 'BuidlGuidl', + '' + )); + } +} diff --git a/packages/hardhat/contracts/BatchRegistry.sol b/packages/hardhat/contracts/BatchRegistry.sol index eb1971f..ee22122 100644 --- a/packages/hardhat/contracts/BatchRegistry.sol +++ b/packages/hardhat/contracts/BatchRegistry.sol @@ -2,13 +2,17 @@ pragma solidity >=0.8.0 <0.9.0; import "@openzeppelin/contracts/access/Ownable.sol"; +import "./BatchGraduationNFT.sol"; contract BatchRegistry is Ownable { + uint16 public immutable BATCH_NUMBER; uint256 constant CHECK_IN_REWARD = 0.01 ether; + BatchGraduationNFT public batchGraduationNFT; mapping(address => bool) public allowList; mapping(address => address) public yourContractAddress; + mapping(address => uint256) public graduatedTokenId; bool public isOpen = true; uint256 public checkedInCounter; @@ -18,7 +22,8 @@ contract BatchRegistry is Ownable { error BatchNotOpen(); error NotAContract(); error NotInAllowList(); - + error AlreadyGraduated(); + error NotCheckedIn(); modifier batchIsOpen() { if (!isOpen) revert BatchNotOpen(); @@ -30,8 +35,10 @@ contract BatchRegistry is Ownable { _; } - constructor(address initialOwner) { + constructor(address initialOwner, uint16 batchNumber) { super.transferOwnership(initialOwner); + batchGraduationNFT = new BatchGraduationNFT(address(this)); + BATCH_NUMBER = batchNumber; } function updateAllowList(address[] calldata builders, bool[] calldata statuses) public onlyOwner { @@ -61,6 +68,14 @@ contract BatchRegistry is Ownable { emit CheckedIn(wasFirstTime, tx.origin, msg.sender); } + function graduate() public { + if (graduatedTokenId[msg.sender] != 0) revert AlreadyGraduated(); + if (yourContractAddress[msg.sender] == address(0)) revert NotCheckedIn(); + + uint256 newTokenId = batchGraduationNFT.mint(msg.sender); + graduatedTokenId[msg.sender] = newTokenId; + } + // Withdraw function for admins in case some builders don't end up checking in function withdraw() public onlyOwner { (bool success, ) = payable(owner()).call{value: address(this).balance}(""); diff --git a/packages/hardhat/deploy/00_deploy_your_contract.ts b/packages/hardhat/deploy/00_deploy_your_contract.ts index 88a2f59..72eb987 100644 --- a/packages/hardhat/deploy/00_deploy_your_contract.ts +++ b/packages/hardhat/deploy/00_deploy_your_contract.ts @@ -2,6 +2,9 @@ import { HardhatRuntimeEnvironment } from "hardhat/types"; import { DeployFunction } from "hardhat-deploy/types"; import { Contract } from "ethers"; +// Update with your Batch number +const BATCH_NUMBER = "8"; + /** * Deploys a contract named "deployYourContract" using the deployer account and * constructor arguments set to the deployer address @@ -25,7 +28,7 @@ const deployYourContract: DeployFunction = async function (hre: HardhatRuntimeEn await deploy("BatchRegistry", { from: deployer, // Contract constructor arguments - args: [deployer], + args: [deployer, BATCH_NUMBER], log: true, // autoMine: can be passed to the deploy function to make the deployment process faster on local networks by // automatically mining the contract deployment transaction. There is no effect on live networks. @@ -34,7 +37,12 @@ const deployYourContract: DeployFunction = async function (hre: HardhatRuntimeEn // Get the deployed contract to interact with it after deploying. const batchRegistry = await hre.ethers.getContract("BatchRegistry", deployer); - console.log("BatchRegistry deployed to:", await batchRegistry.getAddress()); + console.log("\nBatchRegistry deployed to:", await batchRegistry.getAddress()); + console.log("Remember to update the allow list!\n"); + + // The GraduationNFT contract is deployed on the BatchRegistry constructor. + const batchGraduationNFTAddress = await batchRegistry.batchGraduationNFT(); + console.log("BatchGraduation NFT deployed to:", batchGraduationNFTAddress, "\n"); }; export default deployYourContract; diff --git a/packages/nextjs/contracts/externalContracts.ts b/packages/nextjs/contracts/externalContracts.ts index 7ab7d56..bdf69f9 100644 --- a/packages/nextjs/contracts/externalContracts.ts +++ b/packages/nextjs/contracts/externalContracts.ts @@ -233,6 +233,464 @@ const externalContracts = { transferOwnership: "@openzeppelin/contracts/access/Ownable.sol", }, }, + BatchGraduationNFT: { + address: "0x0", + abi: [ + { + inputs: [ + { + internalType: "address", + name: "_batchRegistry", + type: "address", + }, + ], + stateMutability: "nonpayable", + type: "constructor", + }, + { + inputs: [], + name: "NoMetadataSet", + type: "error", + }, + { + inputs: [], + name: "UnauthorizedCaller", + type: "error", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "owner", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "approved", + type: "address", + }, + { + indexed: true, + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + ], + name: "Approval", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "owner", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "operator", + type: "address", + }, + { + indexed: false, + internalType: "bool", + name: "approved", + type: "bool", + }, + ], + name: "ApprovalForAll", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "address", + name: "builder", + type: "address", + }, + { + indexed: false, + internalType: "address", + name: "metadataContract", + type: "address", + }, + ], + name: "MetadataSet", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "from", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "to", + type: "address", + }, + { + indexed: true, + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + ], + name: "Transfer", + type: "event", + }, + { + inputs: [ + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + ], + name: "approve", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "owner", + type: "address", + }, + ], + name: "balanceOf", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "batchRegistry", + outputs: [ + { + internalType: "contract IBatchRegistry", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + ], + name: "getApproved", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "owner", + type: "address", + }, + { + internalType: "address", + name: "operator", + type: "address", + }, + ], + name: "isApprovedForAll", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "builder", + type: "address", + }, + ], + name: "mint", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "name", + outputs: [ + { + internalType: "string", + name: "", + type: "string", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + ], + name: "ownerOf", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "from", + type: "address", + }, + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + ], + name: "safeTransferFrom", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "from", + type: "address", + }, + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + ], + name: "safeTransferFrom", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "operator", + type: "address", + }, + { + internalType: "bool", + name: "approved", + type: "bool", + }, + ], + name: "setApprovalForAll", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "metadataContract", + type: "address", + }, + ], + name: "setMetadataContract", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes4", + name: "interfaceId", + type: "bytes4", + }, + ], + name: "supportsInterface", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "symbol", + outputs: [ + { + internalType: "string", + name: "", + type: "string", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + name: "tokenToMetadataContract", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + ], + name: "tokenURI", + outputs: [ + { + internalType: "string", + name: "", + type: "string", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "from", + type: "address", + }, + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + ], + name: "transferFrom", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + name: "yourGraduationContractAddress", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + ], + }, }, } as const;