diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..93b0aa4 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c4d4bd9..39cc646 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,8 +16,10 @@ jobs: strategy: fail-fast: true - name: Foundry project + name: Will4USNFT Foundry project runs-on: ubuntu-latest + env: + ARBITRUM_GOERLI_RPC_URL: ${{ secrets.ARBITRUM_GOERLI_RPC_URL }} steps: - uses: actions/checkout@v3 with: diff --git a/.gitmodules b/.gitmodules index 690924b..805e2e5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,12 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/chainlink"] + path = lib/chainlink + url = https://github.com/smartcontractkit/chainlink +[submodule "lib/solady"] + path = lib/solady + url = https://github.com/vectorized/solady +[submodule "lib/eas-contracts"] + path = lib/eas-contracts + url = https://github.com/ethereum-attestation-service/eas-contracts diff --git a/DEPLOYMENTS.md b/DEPLOYMENTS.md new file mode 100644 index 0000000..16c5ba3 --- /dev/null +++ b/DEPLOYMENTS.md @@ -0,0 +1,43 @@ +# Will4Us NFT Deployments + +## Arbitrum Mainnet + +| Contract | Address | Link | +| --- | --- | --- | +| Will4UsNFT | 0x0 | [LINK](https://goerli.arbiscan.io/address/0x0) | + +## Arbitrum Testnet + +| Contract | Address | Link | +| --- | --- | --- | +| Will4UsNFT | 0xe2e1f1c872842350c85623c2323914fd24a6c17c | [LINK](https://goerli.arbiscan.io/address/0xe2e1f1c872842350c85623c2323914fd24a6c17c) | + +## Goerli Testnet + +| Contract | Address | Link | +| --- | --- | --- | +| Will4UsNFT | 0x0 | [LINK](https://goerli.etherscan.io/address/0x0) | + +## Base Goerli + +| Contract | Address | Link | +| --- | --- | --- | +| Will4UsNFT | 0x0 | [LINK](https://basescan.org/address/0x0) | + +## Base Mainnet + +| Contract | Address | Link | +| --- | --- | --- | +| Will4UsNFT | 0x0 | [LINK](https://basescan.org/address/0x0) | + +## Optimism Goerli + +| Contract | Address | Link | +| --- | --- | --- | +| Will4UsNFT | 0x0 | [LINK](https://optimistic.etherscan.io/address/0x0) | + +## Optimism Mainnet + +| Contract | Address | Link | +| --- | --- | --- | +| Will4UsNFT | 0x0 | [LINK](https://optimistic.etherscan.io/address/0x0) | diff --git a/foundry.toml b/foundry.toml index 25b918f..e8534b0 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,5 +2,38 @@ src = "src" out = "out" libs = ["lib"] +broadcast = "broadcast" + +[rpc_endpoints] +arbitrumGoerli = "${ARBITRUM_GOERLI_RPC_URL}" + +[etherscan] +arbitrumGoerli = { key = "${ARBITRUM_API_KEY}" } + + +[fuzz] +runs = 256 +max_test_rejects = 65536 +seed = '0x3e8' +dictionary_weight = 40 +include_storage = true +include_push_bytes = true + +[invariant] +runs = 256 +depth = 15 +fail_on_revert = false +call_override = false +dictionary_weight = 80 +include_storage = true +include_push_bytes = true +shrink_sequence = true + +[fmt] +line_length = 100 +tab_width = 4 +bracket_spacing = true + +# Remappings in remappings.txt # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/chainlink b/lib/chainlink new file mode 160000 index 0000000..b96cb80 --- /dev/null +++ b/lib/chainlink @@ -0,0 +1 @@ +Subproject commit b96cb806e6ea648799e31a71fb1803607d79cde4 diff --git a/lib/eas-contracts b/lib/eas-contracts new file mode 160000 index 0000000..9df0f78 --- /dev/null +++ b/lib/eas-contracts @@ -0,0 +1 @@ +Subproject commit 9df0f78603b4c686b4800a711f4f5ce355cc801d diff --git a/lib/solady b/lib/solady new file mode 160000 index 0000000..22fa9a5 --- /dev/null +++ b/lib/solady @@ -0,0 +1 @@ +Subproject commit 22fa9a52a8012d7bf95ec7b042d99710c524533b diff --git a/remappings.txt b/remappings.txt index f810763..ceeca13 100644 --- a/remappings.txt +++ b/remappings.txt @@ -3,3 +3,5 @@ ds-test/=lib/forge-std/lib/ds-test/src/ erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/ forge-std/=lib/forge-std/src/ openzeppelin-contracts/=lib/openzeppelin-contracts/ +@chainlink/=lib/chainlink/contracts/ +eas-contracts/=lib/eas-contracts/contracts/ \ No newline at end of file diff --git a/script/Will4USNFT.s.sol b/script/Will4USNFT.s.sol index 68b8598..be5c2bc 100644 --- a/script/Will4USNFT.s.sol +++ b/script/Will4USNFT.s.sol @@ -1,25 +1,28 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity 0.8.20; -import {Script} from "forge-std/Script.sol"; +import { Script } from "forge-std/Script.sol"; +// import { Test, console2 } from "forge-std/Test.sol"; -import {Will4USNFT} from "../src/Will4USNFT.sol"; +import { Will4USNFT } from "../src/Will4USNFT.sol"; /// @notice This script is used to deploy the Will4USNFT contract /// @dev Use this to run /// 'source .env' if you are using a .env file for your rpc-url /// 'forge script script/Will4USNFT.s.sol:Will4USNFTScript --rpc-url $GOERLI_RPC_URL --broadcast --verify -vvvv' contract Will4USNFTScript is Script { - function setUp() public {} + function setUp() public { } function run() public { uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); address deployerAddress = vm.envAddress("DEPLOYER_ADDRESS"); + // string memory url = vm.rpcUrl("arbitrumGoerli"); + // assertEq(url, "https://arb-goerli.g.alchemy.com/v2/RqTiyvS7OspxaAQUQupKKCTjmf94JL-I"); vm.startBroadcast(deployerPrivateKey); - Will4USNFT nftContract = new Will4USNFT(deployerAddress); + new Will4USNFT(deployerAddress, deployerAddress, deployerAddress, 5); - nftContract.awardCampaignItem(deployerAddress, "https://placeholder.com/1", 1); + // nftContract.awardCampaignItem(deployerAddress, 1); vm.stopBroadcast(); } diff --git a/src/APIConsumer.sol b/src/APIConsumer.sol new file mode 100644 index 0000000..d00620f --- /dev/null +++ b/src/APIConsumer.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import "@chainlink/src/v0.8/ChainlinkClient.sol"; +import "@chainlink/src/v0.8/shared/access/ConfirmedOwner.sol"; + +/** + * @title The APIConsumer contract + * @notice An API Consumer contract that makes GET requests + */ +contract APIConsumer is ChainlinkClient, ConfirmedOwner { + using Chainlink for Chainlink.Request; + + bytes32 private jobId; + uint256 private fee; + + mapping(address => bool) public isKYCApproved; + + event DataFullfilled(bytes32 requestId, bool isKYCApproved); + + /** + * @notice Initialize the link token and target oracle + * + * Sepolia Testnet details: + * Link Token: 0x779877A7B0D9E8603169DdbD7836e478b4624789 + * Oracle: 0x6090149792dAAeE9D1D568c9f9a6F6B46AA29eFD (Chainlink DevRel) + * jobId: ca98366cc7314957b8c012c72f05aeeb + * + */ + constructor() ConfirmedOwner(msg.sender) { + setChainlinkToken(0x779877A7B0D9E8603169DdbD7836e478b4624789); + setChainlinkOracle(0x6090149792dAAeE9D1D568c9f9a6F6B46AA29eFD); + jobId = "ca98366cc7314957b8c012c72f05aeeb"; + fee = (1 * LINK_DIVISIBILITY) / 10; // 0,1 * 10**18 (Varies by network and job) + } + /** + * @notice Creates a Chainlink request to retrieve API response and update the mapping + * + * @return requestId - ID of the request + */ + + function requestKYCData() public returns (bytes32 requestId) { + Chainlink.Request memory request = + buildChainlinkRequest(jobId, address(this), this.fulfill.selector); + + // Set the URL to perform the GET request on + request.add("get", "set url here"); + + // Set the path to find the desired data in the API response, where the response format is: + // {"RAW": + // {"ETH": + // {"USD": + // { + // "VOLUME24HOUR": xxx.xxx, + // } + // } + // } + // } + // Chainlink node versions prior to 1.0.0 supported this format + // request.add("path", "RAW.ETH.USD.VOLUME24HOUR"); + request.add("path", ""); + + // Sends the request + return sendChainlinkRequest(request, fee); + } + + /** + * Receive the response in the form of uint256 + */ + function fulfill(bytes32 _requestId, bool _isKYCApproved) + public + recordChainlinkFulfillment(_requestId) + { + isKYCApproved[address(0)] = _isKYCApproved; + + emit DataFullfilled(_requestId, _isKYCApproved); + } + + /** + * Allow withdraw of Link tokens from the contract + */ + function withdrawLink() public onlyOwner { + LinkTokenInterface link = LinkTokenInterface(chainlinkTokenAddress()); + require(link.transfer(msg.sender, link.balanceOf(address(this))), "Unable to transfer"); + } +} diff --git a/src/FunctionConsumer.sol b/src/FunctionConsumer.sol new file mode 100644 index 0000000..50d6fc2 --- /dev/null +++ b/src/FunctionConsumer.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import { FunctionsClient } from "@chainlink/src/v0.8/functions/dev/1_0_0/FunctionsClient.sol"; +import { ConfirmedOwner } from "@chainlink/src/v0.8/shared/access/ConfirmedOwner.sol"; +import { FunctionsRequest } from + "@chainlink/src/v0.8/functions/dev/1_0_0/libraries/FunctionsRequest.sol"; + +/** + * THIS IS AN EXAMPLE CONTRACT THAT USES HARDCODED VALUES FOR CLARITY. + * THIS IS AN EXAMPLE CONTRACT THAT USES UN-AUDITED CODE. + * DO NOT USE THIS CODE IN PRODUCTION. + */ +contract FunctionsConsumer is FunctionsClient, ConfirmedOwner { + using FunctionsRequest for FunctionsRequest.Request; + + bytes32 public s_lastRequestId; + bytes public s_lastResponse; + bytes public s_lastError; + + error UnexpectedRequestID(bytes32 requestId); + + event Response(bytes32 indexed requestId, bytes response, bytes err); + + constructor(address router) FunctionsClient(router) ConfirmedOwner(msg.sender) { } + + /** + * @notice Send a simple request + * @param source JavaScript source code + * @param encryptedSecretsUrls Encrypted URLs where to fetch user secrets + * @param donHostedSecretsSlotID Don hosted secrets slotId + * @param donHostedSecretsVersion Don hosted secrets version + * @param args List of arguments accessible from within the source code + * @param bytesArgs Array of bytes arguments, represented as hex strings + * @param subscriptionId Billing ID + */ + function sendRequest( + string memory source, + bytes memory encryptedSecretsUrls, + uint8 donHostedSecretsSlotID, + uint64 donHostedSecretsVersion, + string[] memory args, + bytes[] memory bytesArgs, + uint64 subscriptionId, + uint32 gasLimit, + bytes32 jobId + ) external onlyOwner returns (bytes32 requestId) { + FunctionsRequest.Request memory req; + req.initializeRequestForInlineJavaScript(source); + if (encryptedSecretsUrls.length > 0) { + req.addSecretsReference(encryptedSecretsUrls); + } else if (donHostedSecretsVersion > 0) { + req.addDONHostedSecrets(donHostedSecretsSlotID, donHostedSecretsVersion); + } + if (args.length > 0) req.setArgs(args); + if (bytesArgs.length > 0) req.setBytesArgs(bytesArgs); + s_lastRequestId = _sendRequest(req.encodeCBOR(), subscriptionId, gasLimit, jobId); + return s_lastRequestId; + } + + /** + * @notice Send a pre-encoded CBOR request + * @param request CBOR-encoded request data + * @param subscriptionId Billing ID + * @param gasLimit The maximum amount of gas the request can consume + * @param jobId ID of the job to be invoked + * @return requestId The ID of the sent request + */ + function sendRequestCBOR( + bytes memory request, + uint64 subscriptionId, + uint32 gasLimit, + bytes32 jobId + ) external onlyOwner returns (bytes32 requestId) { + s_lastRequestId = _sendRequest(request, subscriptionId, gasLimit, jobId); + return s_lastRequestId; + } + + /** + * @notice Store latest result/error + * @param requestId The request ID, returned by sendRequest() + * @param response Aggregated response from the user code + * @param err Aggregated error from the user code or from the execution pipeline + * Either response or error parameter will be set, but never both + */ + function fulfillRequest(bytes32 requestId, bytes memory response, bytes memory err) + internal + override + { + if (s_lastRequestId != requestId) { + revert UnexpectedRequestID(requestId); + } + s_lastResponse = response; + s_lastError = err; + emit Response(requestId, s_lastResponse, s_lastError); + } +} diff --git a/src/IVERC20BaseToken.sol b/src/IVERC20BaseToken.sol new file mode 100644 index 0000000..5843ef0 --- /dev/null +++ b/src/IVERC20BaseToken.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity 0.8.20; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; +import "@openzeppelin/contracts/access/AccessControl.sol"; + +/// @title IVERC20BaseToken +/// @notice This is the base token contract used for the IVToken contracts. +/// @dev This contract is used to deploy the projects ERC20 token contracts. +/// @author @codenamejason +contract IVERC20BaseToken is + ERC20, + ERC20Burnable, + ERC20Pausable, + AccessControl, + ERC20Permit, + ERC20Votes +{ + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + modifier onlyAdminAndPauser() { + require( + hasRole(DEFAULT_ADMIN_ROLE, msg.sender) || hasRole(PAUSER_ROLE, msg.sender), + "Caller is not an admin or pauser" + ); + _; + } + + modifier onlyAdminAndMinter() { + require( + hasRole(DEFAULT_ADMIN_ROLE, msg.sender) || hasRole(MINTER_ROLE, msg.sender), + "Caller is not an admin or minter" + ); + _; + } + + constructor( + address defaultAdmin, + address minter, + address pauser, + string memory name, + string memory symbol + ) ERC20(name, symbol) ERC20Permit(name) { + _grantRole(DEFAULT_ADMIN_ROLE, defaultAdmin); + _grantRole(MINTER_ROLE, minter); + _grantRole(PAUSER_ROLE, pauser); + } + + function pause() public onlyAdminAndPauser { + _pause(); + } + + function unpause() public onlyAdminAndPauser { + _unpause(); + } + + function mint(address to, uint256 amount) public onlyAdminAndMinter { + _mint(to, amount); + } + + // The following functions are overrides required by Solidity. + + function _update(address from, address to, uint256 value) + internal + override(ERC20, ERC20Pausable, ERC20Votes) + { + super._update(from, to, value); + } + + function nonces(address owner) public view override(ERC20Permit, Nonces) returns (uint256) { + return super.nonces(owner); + } + + function transfer(address, uint256) public pure override(ERC20) returns (bool) { + revert("Transfer is disabled"); + } + + function transferFrom(address, address, uint256) public pure override(ERC20) returns (bool) { + revert("Transfer is disabled"); + } +} diff --git a/src/IVERC20TokenContractFactory.sol b/src/IVERC20TokenContractFactory.sol new file mode 100644 index 0000000..09037ab --- /dev/null +++ b/src/IVERC20TokenContractFactory.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import { IVERC20BaseToken } from "./IVERC20BaseToken.sol"; +import { Errors } from "./library/Errors.sol"; + +/// @title IVTokenContractFactory +/// @author @codenamejason +/// @dev IVTokenContractFactory is used to deploy the projects ERC20 token contracts. Please +/// see the README for more information. +contract IVERC20TokenContractFactory { + /// ====================== + /// ======= Events ======= + /// ====================== + + /// @notice Emitted when a contract is deployed. + event Deployed(address indexed deployed); + + /// ====================== + /// ======= Storage ====== + /// ====================== + + struct Token { + address defaultAdmin; + address minter; + address pauser; + string name; + string symbol; + address contractAddress; + } + + /// @notice Collection of authorized deployers. + mapping(address => bool) public isDeployer; + + /// @notice Collection of deployed contracts. + mapping(address => Token) public deployedTokens; + + /// ====================== + /// ======= Modifiers ==== + /// ====================== + + /// @notice Modifier to ensure the caller is authorized to deploy and returns if not. + modifier onlyDeployer() { + _checkIsDeployer(); + _; + } + + /// ====================== + /// ===== Constructor ==== + /// ====================== + + /// @notice On deployment sets the 'msg.sender' to allowed deployer. + constructor() { + isDeployer[msg.sender] = true; + } + + /// =============================== + /// ====== Internal Functions ===== + /// =============================== + + /// @notice Checks if the caller is authorized to deploy. + function _checkIsDeployer() internal view { + if (!isDeployer[msg.sender]) revert Errors.Unauthorized(msg.sender); + } + + /// =============================== + /// ====== External Functions ===== + /// =============================== + + /// @notice Deploys a token contract. + /// @dev Used for our deployments. + /// @param _defaultAdmin Address of the default admin + /// @param _minter Address of the minter + /// @param _pauser Address of the pauser + /// @param _name Name of the token + /// @param _symbol Symbol of the token + /// @return deployedContract Address of the deployed contract + function create( + address _defaultAdmin, + address _minter, + address _pauser, + string memory _name, + string memory _symbol + ) external payable onlyDeployer returns (address deployedContract) { + deployedContract = + address(new IVERC20BaseToken(_defaultAdmin, _minter, _pauser, _name, _symbol)); + + // Set the token to the deployedTokens mapping + deployedTokens[deployedContract] = Token({ + defaultAdmin: _defaultAdmin, + minter: _minter, + pauser: _pauser, + name: _name, + symbol: _symbol, + contractAddress: deployedContract + }); + + emit Deployed(deployedContract); + } + + /// @notice Set the allowed deployer. + /// @dev 'msg.sender' must be a deployer. + /// @param _deployer Address of the deployer to set + /// @param _allowedToDeploy Boolean to set the deployer to + function setDeployer(address _deployer, bool _allowedToDeploy) external onlyDeployer { + // Set the deployer to the allowedToDeploy mapping + isDeployer[_deployer] = _allowedToDeploy; + } +} diff --git a/src/IVERC721BaseToken.sol b/src/IVERC721BaseToken.sol new file mode 100644 index 0000000..9f6a8c9 --- /dev/null +++ b/src/IVERC721BaseToken.sol @@ -0,0 +1,331 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Pausable.sol"; +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol"; +import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Votes.sol"; + +import { Errors } from "./library/Errors.sol"; +import { Structs } from "./library/Structs.sol"; +import { RedemtionModule } from "./RedemtionModule.sol"; + +contract IVERC721BaseToken is + RedemtionModule, + ERC721, + ERC721Enumerable, + ERC721URIStorage, + ERC721Pausable, + AccessControl, + ERC721Burnable, + EIP712, + ERC721Votes +{ + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + uint256 private _tokenIds; + uint256 public classIds; + uint256 public totalClassesSupply; + uint256 public maxMintablePerClass; + + mapping(uint256 => Structs.Class) public classes; + mapping(address => mapping(uint256 => uint256)) public mintedPerClass; + + /** + * Events ************ + */ + event ItemAwarded(uint256 indexed tokenId, address indexed recipient, uint256 indexed classId); + event TokenMetadataUpdated( + address indexed sender, uint256 indexed classId, uint256 indexed tokenId, string tokenURI + ); + event ClassAdded(uint256 indexed classId, string metadata); + event UpdatedClassTokenSupply(uint256 indexed classId, uint256 supply); + event UpdatedMaxMintablePerClass(uint256 maxMintable); + + /** + * Modifiers ************ + */ + + /** + * @notice Checks if the sender is a campaign member + * @dev This modifier is used to check if the sender is a campaign member + * @param sender The sender address + */ + modifier onlyMinter(address sender) { + if (!hasRole(MINTER_ROLE, sender)) { + revert Errors.Unauthorized(sender); + } + _; + } + + constructor( + address _defaultAdmin, + address _pauser, + address _minter, + string memory _name, + string memory _symbol + ) ERC721(_name, _symbol) EIP712(_name, "1") { + _grantRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _grantRole(PAUSER_ROLE, _pauser); + _grantRole(MINTER_ROLE, _minter); + } + + /** + * External Functions ***** + */ + + /** + * @notice Awards nft to address + * @dev This function is only callable by staff + * @param _recipient The recipient of the item + * @param _classId The class ID + */ + function awardItem(address _recipient, uint256 _classId) + external + onlyMinter(msg.sender) + returns (uint256) + { + if (mintedPerClass[_recipient][_classId] > maxMintablePerClass) { + revert Errors.MaxMintablePerClassReached(_recipient, _classId, maxMintablePerClass); + } + + uint256 tokenId = _mintItem(_recipient, _classId); + mintedPerClass[_recipient][_classId]++; + + emit ItemAwarded(tokenId, _recipient, _classId); + + return tokenId; + } + + /** + * @notice Awards nft to a batch of addresses + * @dev This function is only callable by staff + * @param _recipients The recipients of the item + * @param _classIds The class IDs + */ + function batchAwardItem(address[] memory _recipients, uint256[] memory _classIds) + external + onlyMinter(msg.sender) + returns (uint256[] memory) + { + uint256 length = _recipients.length; + uint256[] memory tokenIds = new uint256[](length); + + for (uint256 i = 0; i < length;) { + if (mintedPerClass[_recipients[i]][_classIds[i]] > maxMintablePerClass) { + revert("You have reached the max mintable for this class"); + } + + tokenIds[i] = _mintItem(_recipients[i], _classIds[i]); + mintedPerClass[_recipients[i]][_classIds[i]]++; + + emit ItemAwarded(tokenIds[i], _recipients[i], _classIds[i]); + + unchecked { + ++i; + } + } + + return tokenIds; + } + + /** + * @notice Adds a new class + * @dev This function is only callable by staff + * @param _name The name of the class + * @param _description The description of the class + * @param _imagePointer The image pointer for the class + * @param _metadata The metadata pointer for the class + * @param _supply The total supply of the class + */ + function addClass( + string memory _name, + string memory _description, + string memory _imagePointer, + string memory _metadata, + uint256 _supply + ) external onlyMinter(msg.sender) { + uint256 id = ++classIds; + totalClassesSupply += _supply; + + classes[id] = Structs.Class(id, _supply, 0, _name, _description, _imagePointer, _metadata); + + emit ClassAdded(id, _metadata); + } + + /** + * @notice Returns all classes + */ + function getAllClasses() public view returns (Structs.Class[] memory) { + Structs.Class[] memory _classes = new Structs.Class[](classIds); + + for (uint256 i = 0; i < classIds; i++) { + _classes[i] = classes[i + 1]; + } + + return _classes; + } + + /** + * @notice Updates the token metadata + * @dev This function is only callable by staff - only use if you really need to + * @param _tokenId The token ID to update + * @param _classId The class ID + * @param _newTokenURI The new token URI 🚨 must be a pointer to a json object 🚨 + * @return The new token URI + */ + function updateTokenMetadata(uint256 _classId, uint256 _tokenId, string memory _newTokenURI) + external + onlyRole(DEFAULT_ADMIN_ROLE) + returns (string memory) + { + if (super.ownerOf(_tokenId) != address(0)) { + _setTokenURI(_tokenId, _newTokenURI); + + emit TokenMetadataUpdated(msg.sender, _classId, _tokenId, tokenURI(_tokenId)); + + return tokenURI(_tokenId); + } else { + revert Errors.InvalidTokenId(_tokenId); + } + } + + /** + * @notice Sets the class token supply + * @dev This function is only callable by staff + * @param _classId The class ID + * @param _supply The new supply + */ + function setClassTokenSupply(uint256 _classId, uint256 _supply) + external + onlyMinter(msg.sender) + { + uint256 currentSupply = classes[_classId].supply; + uint256 minted = classes[_classId].minted; + + if (_supply < currentSupply) { + // if the new supply is less than the current supply, we need to check if the new supply is less than the minted + // if it is, then we need to revert + if (_supply < minted) { + revert Errors.NewSupplyTooLow(minted, _supply); + } + } + + // update the total supply + totalClassesSupply = totalClassesSupply - currentSupply + _supply; + classes[_classId].supply = _supply; + + emit UpdatedClassTokenSupply(_classId, _supply); + } + + /** + * View Functions ****** + */ + + /** + * @notice Returns the total supply for a class + * @param _classId The class ID + */ + function getTotalSupplyForClass(uint256 _classId) external view returns (uint256) { + return classes[_classId].supply; + } + + /** + * @notice Returns the total supply for all classes + */ + function getTotalSupplyForAllClasses() external view returns (uint256) { + return totalClassesSupply; + } + + /** + * @notice Returns `_baseURI` for the `tokenURI` + */ + function _baseURI() internal pure override returns (string memory) { + // TODO: 🚨 update this when production ready 🚨 + return string.concat( + "https://pharo.mypinata.cloud/ipfs/QmSnzdnhtCuJ6yztHmtYFT7eU2hFF17QNM6rsNohFn6csg/" + ); + } + + /** + * @notice Returns the `tokenURI` + * @param _classId The class ID + * @param _tokenId The token ID + */ + function getTokenURI(uint256 _classId, uint256 _tokenId) public pure returns (string memory) { + string memory classId = Strings.toString(_classId); + string memory tokenId = Strings.toString(_tokenId); + + return string.concat(classId, "/", tokenId, ".json"); + } + + /** + * @notice Returns the owner of the token + * @param _tokenId The token ID + */ + function getOwnerOfToken(uint256 _tokenId) external view returns (address) { + return super.ownerOf(_tokenId); + } + + /** + * Internal Functions ****** + */ + + /** + * @notice Mints a new campaign item + * @param _recipient The recipient of the item + * @param _classId The class ID + */ + function _mintItem(address _recipient, uint256 _classId) internal returns (uint256) { + uint256 tokenId = ++_tokenIds; + + // update the class minted count + classes[_classId].minted++; + + _safeMint(_recipient, tokenId); + _setTokenURI(tokenId, getTokenURI(_classId, tokenId)); + + return tokenId; + } + + /** + * Overrides + */ + + function _update(address to, uint256 tokenId, address auth) + internal + override(ERC721, ERC721Enumerable, ERC721Pausable, ERC721Votes) + returns (address) + { + return super._update(to, tokenId, auth); + } + + function _increaseBalance(address account, uint128 value) + internal + override(ERC721, ERC721Enumerable, ERC721Votes) + { + super._increaseBalance(account, value); + } + + function tokenURI(uint256 tokenId) + public + view + override(ERC721, ERC721URIStorage) + returns (string memory) + { + return super.tokenURI(tokenId); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC721, ERC721Enumerable, ERC721URIStorage, AccessControl) + returns (bool) + { + return super.supportsInterface(interfaceId); + } +} diff --git a/src/IVERC721TokenContractFactory.sol b/src/IVERC721TokenContractFactory.sol new file mode 100644 index 0000000..e29d596 --- /dev/null +++ b/src/IVERC721TokenContractFactory.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import { IVERC721BaseToken } from "./IVERC721BaseToken.sol"; +import { Errors } from "./library/Errors.sol"; + +contract IVERC721TokenContractFactory { + /// ====================== + /// ======= Events ======= + /// ====================== + + /// @notice Emitted when a contract is deployed. + event Deployed(address indexed deployed); + + /// ====================== + /// ======= Storage ====== + /// ====================== + + struct Token { + address defaultAdmin; + address minter; + address pauser; + string name; + string symbol; + address contractAddress; + } + + /// @notice Collection of authorized deployers. + mapping(address => bool) public isDeployer; + + /// @notice Collection of deployed contracts. + mapping(address => Token) public deployedTokens; + + /// ====================== + /// ======= Modifiers ==== + /// ====================== + + /// @notice Modifier to ensure the caller is authorized to deploy and returns if not. + modifier onlyDeployer() { + _checkIsDeployer(); + _; + } + + /// ====================== + /// ===== Constructor ==== + /// ====================== + + /// @notice On deployment sets the 'msg.sender' to allowed deployer. + constructor() { + isDeployer[msg.sender] = true; + } + + /// =============================== + /// ====== Internal Functions ===== + /// =============================== + + /// @notice Checks if the caller is authorized to deploy. + function _checkIsDeployer() internal view { + if (!isDeployer[msg.sender]) revert Errors.Unauthorized(msg.sender); + } + + /// =============================== + /// ====== External Functions ===== + /// =============================== + + /// @notice Deploys a token contract. + /// @dev Used for our deployments. + /// @param _defaultAdmin Address of the default admin + /// @param _minter Address of the minter + /// @param _pauser Address of the pauser + /// @param _name Name of the token + /// @param _symbol Symbol of the token + /// @return deployedContract Address of the deployed contract + function create( + address _defaultAdmin, + address _minter, + address _pauser, + string memory _name, + string memory _symbol + ) external payable onlyDeployer returns (address deployedContract) { + deployedContract = + address(new IVERC721BaseToken(_defaultAdmin, _minter, _pauser, _name, _symbol)); + + // Set the token to the deployedTokens mapping + deployedTokens[deployedContract] = Token({ + defaultAdmin: _defaultAdmin, + minter: _minter, + pauser: _pauser, + name: _name, + symbol: _symbol, + contractAddress: deployedContract + }); + + emit Deployed(deployedContract); + + return deployedContract; + } + + /// @notice Set the allowed deployer. + /// @dev 'msg.sender' must be a deployer. + /// @param _deployer Address of the deployer to set + /// @param _allowedToDeploy Boolean to set the deployer to + function setDeployer(address _deployer, bool _allowedToDeploy) external onlyDeployer { + // Set the deployer to the allowedToDeploy mapping + isDeployer[_deployer] = _allowedToDeploy; + } +} diff --git a/src/IVGovernor.sol b/src/IVGovernor.sol new file mode 100644 index 0000000..80a4225 --- /dev/null +++ b/src/IVGovernor.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/governance/Governor.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorStorage.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol"; + +/// @title IVGovernor +/// @notice This is the governor contract used for IV Projects. +/// @author @codenamejason +contract IVGovernor is + Governor, + GovernorSettings, + GovernorCountingSimple, + GovernorStorage, + GovernorVotes, + GovernorVotesQuorumFraction, + GovernorTimelockControl +{ + constructor(IVotes _token, TimelockController _timelock) + Governor("IVGovernor") + GovernorSettings(7200, /* 1 day */ 50400, /* 1 week */ 0) + GovernorVotes(_token) + GovernorVotesQuorumFraction(4) + GovernorTimelockControl(_timelock) + { } + + // The following functions are overrides required by Solidity. + + function votingDelay() public view override(Governor, GovernorSettings) returns (uint256) { + return super.votingDelay(); + } + + function votingPeriod() public view override(Governor, GovernorSettings) returns (uint256) { + return super.votingPeriod(); + } + + function quorum(uint256 blockNumber) + public + view + override(Governor, GovernorVotesQuorumFraction) + returns (uint256) + { + return super.quorum(blockNumber); + } + + function state(uint256 proposalId) + public + view + override(Governor, GovernorTimelockControl) + returns (ProposalState) + { + return super.state(proposalId); + } + + function proposalNeedsQueuing(uint256 proposalId) + public + view + override(Governor, GovernorTimelockControl) + returns (bool) + { + return super.proposalNeedsQueuing(proposalId); + } + + function proposalThreshold() + public + view + override(Governor, GovernorSettings) + returns (uint256) + { + return super.proposalThreshold(); + } + + function _propose( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description, + address proposer + ) internal override(Governor, GovernorStorage) returns (uint256) { + return super._propose(targets, values, calldatas, description, proposer); + } + + function _queueOperations( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) returns (uint48) { + return super._queueOperations(proposalId, targets, values, calldatas, descriptionHash); + } + + function _executeOperations( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) { + super._executeOperations(proposalId, targets, values, calldatas, descriptionHash); + } + + function _cancel( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) returns (uint256) { + return super._cancel(targets, values, calldatas, descriptionHash); + } + + function _executor() + internal + view + override(Governor, GovernorTimelockControl) + returns (address) + { + return super._executor(); + } +} diff --git a/src/IVOccurrenceManager.sol b/src/IVOccurrenceManager.sol new file mode 100644 index 0000000..2edcdda --- /dev/null +++ b/src/IVOccurrenceManager.sol @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import { IVStaffManager } from "./IVStaffManager.sol"; +import { IIVOccurrenceManager } from "./interfaces/IIVOccurrenceManager.sol"; +import { Enums } from "./library/Enums.sol"; +import { Structs } from "./library/Structs.sol"; +import { Errors } from "./library/Errors.sol"; + +// import { AccessControl } from "@openzeppelin/contracts/access/AccessControl.sol"; + +/** + * @title IVOccurrenceManager + * @notice The IVOccurrenceManager contract is responsible for managing the occurrences + * @dev We use the term occurrence to describe an event, appointment, or any other type of gathering. + * @author @codenamejason + */ +contract IVOccurrenceManager is IIVOccurrenceManager, IVStaffManager { + bytes32 public constant CREATOR_ROLE = keccak256("CREATOR_ROLE"); + + mapping(bytes32 => Structs.Occurrence) public occurrences; + uint256 private _occurrenceCount; + + modifier onlyCreator() { + if (!hasRole(CREATOR_ROLE, msg.sender)) { + revert Errors.NotCreator(msg.sender); + } + _; + } + + modifier occurrenceExists(bytes32 _occurenceIdId) { + if (occurrences[_occurenceIdId].id != _occurenceIdId) { + revert Errors.OccurrenceDoesNotExist(_occurenceIdId); + } + _; + } + + constructor(address _defaultAdmin) IVStaffManager(_defaultAdmin) { + _grantRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + } + + /** + * @notice Create an occurrence + * @param _name The name of the occurrence + * @param _description The description of the occurrence + * @param _start The start time of the occurrence + * @param _end The end time of the occurrence + * @param _price The price of the occurrence + * @param _token The token address of the occurrence + * @param _staff The staff addresses of the occurrence + * @param _metadata The metadata of the occurrence + * @return The id of the occurrence + */ + function createOccurrence( + string memory _name, + string memory _description, + uint256 _start, + uint256 _end, + uint256 _price, + address _token, + address[] memory _staff, + Structs.Metadata memory _metadata + ) external returns (bytes32) { + return + _createOccurrence(_name, _description, _start, _end, _price, _token, _staff, _metadata); + } + + function updateOccurrence( + bytes32 _occurenceId, + string memory _name, + string memory _description, + uint256 _start, + uint256 _end, + uint256 _price, + address _token, + address[] memory _staff, + Structs.Metadata memory _metadata + ) external onlyCreator occurrenceExists(_occurenceId) { + return _updateOccurrence( + _occurenceId, _name, _description, _start, _end, _price, _token, _staff, _metadata + ); + } + + function getOccurrence(bytes32 _occurenceId) + external + view + occurrenceExists(_occurenceId) + returns (Structs.Occurrence memory) + { + return occurrences[_occurenceId]; + } + + function hostOccurrence(bytes32 _occurenceId, address[] memory _attendees) + external + onlyCreator + occurrenceExists(_occurenceId) + { + occurrences[_occurenceId].status = Enums.Status.Hosted; + } + + function recognizeOccurrence(bytes32 _occurenceId, Structs.Metadata memory _content) + external + onlyStaff + occurrenceExists(_occurenceId) + { + occurrences[_occurenceId].status = Enums.Status.Recognized; + } + + function getStaffMemberByOccurrenceId(bytes32 _occurenceId, address _member) + external + view + occurrenceExists(_occurenceId) + returns (Structs.Staff memory) + { + return staff[_member]; + } + + function getStaffMembersForOccurrenceId(bytes32 _occurenceId) + external + view + occurrenceExists(_occurenceId) + returns (Structs.Staff[] memory) + { + Structs.Occurrence memory occurrence = occurrences[_occurenceId]; + Structs.Staff[] memory _staff = new Structs.Staff[](occurrence.staff.length); + + for (uint256 i = 0; i < occurrence.staff.length; i++) { + _staff[i] = staff[occurrence.staff[i]]; + } + + return _staff; + } + + function getOccurrences() external view returns (Structs.Occurrence[] memory) { + Structs.Occurrence[] memory _occurrences = new Structs.Occurrence[](_occurrenceCount); + + for (uint256 i = 0; i < _occurrenceCount; i++) { + // FIXME: this is not the correct way to do this + _occurrences[i] = occurrences[keccak256(abi.encodePacked(i))]; + } + + return _occurrences; + } + + function getOccurrenceById(bytes32 _occurenceIdId) + external + view + returns (Structs.Occurrence memory) + { + return occurrences[_occurenceIdId]; + } + + /** + * Internal Functions + */ + + function _createOccurrence( + string memory _name, + string memory _description, + uint256 _start, + uint256 _end, + uint256 _price, + address _token, + address[] memory _staff, + Structs.Metadata memory _metadata + ) internal returns (bytes32) { + Structs.Occurrence memory _occurenceId = Structs.Occurrence({ + id: keccak256(abi.encodePacked(_name, _start, _end)), + creator: msg.sender, + name: _name, + description: _description, + start: _start, + end: _end, + price: _price, + token: _token, + status: Enums.Status.Pending, + staff: _staff, + metadata: _metadata, + attendees: new address[](9999) + }); + + occurrences[_occurenceId.id] = _occurenceId; + _occurrenceCount++; + + return _occurenceId.id; + } + + function _updateOccurrence( + bytes32 _occurenceId, + string memory _name, + string memory _description, + uint256 _start, + uint256 _end, + uint256 _price, + address _token, + address[] memory _staff, + Structs.Metadata memory _metadata + ) internal { + Structs.Occurrence memory _occurence = occurrences[_occurenceId]; + + _occurence.name = _name; + _occurence.description = _description; + _occurence.start = _start; + _occurence.end = _end; + _occurence.price = _price; + _occurence.token = _token; + _occurence.staff = _staff; + _occurence.metadata = _metadata; + + occurrences[_occurenceId] = _occurence; + } +} diff --git a/src/IVStaffManager.sol b/src/IVStaffManager.sol new file mode 100644 index 0000000..5b04f13 --- /dev/null +++ b/src/IVStaffManager.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import { Enums } from "./library/Enums.sol"; +import { Structs } from "../src/library/Structs.sol"; +import { Errors } from "../src/library/Errors.sol"; + +import { AccessControl } from "@openzeppelin/contracts/access/AccessControl.sol"; + +contract IVStaffManager is AccessControl { + bytes32 public constant STAFF_ROLE = keccak256("STAFF_ROLE"); + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + mapping(address => Structs.Staff) public staff; + + modifier onlyAdmin() { + require( + hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "IVOccurrenceManager: caller is not an admin" + ); + _; + } + + modifier onlyStaff() { + require(hasRole(STAFF_ROLE, msg.sender), "IVOccurrenceManager: caller is not a staff"); + _; + } + + constructor(address _defaultAdmin) { + _grantRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + } + + function addStaffMember( + address _member, + // uint256[] memory _levels, + Structs.Metadata memory _metadata + ) external onlyAdmin { + Structs.Staff memory _staff = Structs.Staff({ + id: keccak256(abi.encodePacked(_member)), + member: _member, + metadata: _metadata, + // levels: _levels, + status: Enums.Status.Pending + }); + + _grantRole(STAFF_ROLE, _member); + + // for (uint256 i = 0; i < _levels.length; i++) { + // _staff.levels[_member].push(_levels[i]); + // } + + staff[_staff.member] = _staff; + } + + function updateStaffMember( + address _member, + // uint256[] memory _levels, + Structs.Metadata memory _metadata + ) external onlyAdmin { + Structs.Staff memory _staff = Structs.Staff({ + id: keccak256(abi.encodePacked(_member)), + member: _member, + metadata: _metadata, + // levels: _levels, + status: Enums.Status.Pending + }); + + // for (uint256 i = 0; i < _levels.length; i++) { + // _staff.levels[_member].push(_levels[i]); + // } + + staff[_staff.member] = _staff; + } + + function updateStaffMemberStatus(address _member, Enums.Status _status) external onlyAdmin { + Structs.Staff memory _staff = staff[_member]; + _staff.status = _status; + + staff[_staff.member] = _staff; + } + + function addStaffMemberMinterRole(address _member) external onlyAdmin { + _grantRole(MINTER_ROLE, _member); + } + + /** + * @notice Removes a campaign member + * @dev This function is only callable by the owner + * @param _member The member to remove + */ + function removeCampaignMember(address _member) external onlyRole(DEFAULT_ADMIN_ROLE) { + revokeRole(MINTER_ROLE, _member); + } +} diff --git a/src/RedemtionModule.sol b/src/RedemtionModule.sol new file mode 100644 index 0000000..15caab3 --- /dev/null +++ b/src/RedemtionModule.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import { Errors } from "./library/Errors.sol"; +import { Structs } from "./library/Structs.sol"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract RedemtionModule { + // occurrenceId => token => bool + mapping(bytes32 => mapping(uint256 => bool)) public redeemed; + + constructor() { } + + /** + * @notice Returns if the token has been redeemed for an event + * @param _occurrenceId The event ID + * @param _tokenId The token ID + * @return bool Returns true if the token has been redeemed + */ + function isRedeemed(bytes32 _occurrenceId, uint256 _tokenId) external view returns (bool) { + return redeemed[_occurrenceId][_tokenId]; + } + + function redeem(bytes32 _occurrenceId, uint256 _tokenId, address _recipient) external { + if (_recipient == address(0)) revert Errors.ZeroAddress(); + if (redeemed[_occurrenceId][_tokenId]) { + revert Errors.AlreadyRedeemed(_occurrenceId, _tokenId); + } + + redeemed[_occurrenceId][_tokenId] = true; + } +} diff --git a/src/Will4USNFT.sol b/src/Will4USNFT.sol index 4b67fc6..9aec2a4 100644 --- a/src/Will4USNFT.sol +++ b/src/Will4USNFT.sol @@ -1,25 +1,36 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity 0.8.20; -import {ERC721URIStorage} from "openzeppelin-contracts/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; -import {IERC721} from "openzeppelin-contracts/contracts/token/ERC721/IERC721.sol"; -import {ERC721} from "openzeppelin-contracts/contracts/token/ERC721/ERC721.sol"; -import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; +import { Errors } from "./library/Errors.sol"; + +import { ERC721URIStorage } from + "openzeppelin-contracts/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; +import { ERC721 } from "openzeppelin-contracts/contracts/token/ERC721/ERC721.sol"; +import { AccessControl } from "@openzeppelin/contracts/access/AccessControl.sol"; +import { Strings } from "openzeppelin-contracts/contracts/utils/Strings.sol"; /// @notice This contract is the Main NFT contract for the Will 4 US Campaign /// @dev This contract is used to mint NFTs for the Will 4 US Campaign /// @author @codenamejason -contract Will4USNFT is ERC721URIStorage, Ownable { +contract Will4USNFT is ERC721URIStorage, AccessControl { + using Strings for uint256; /** * State Variables ******** */ + + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + uint256 private _tokenIds; uint256 public classIds; - - uint256 public mintCounter; + uint256 public totalClassesSupply; + uint256 public maxMintablePerClass; mapping(uint256 => Class) public classes; mapping(address => bool) public campaignMembers; + mapping(address => mapping(uint256 => uint256)) public mintedPerClass; + // occurrenceId => token => bool + mapping(bytes32 => mapping(uint256 => bool)) public redeemed; struct Class { uint256 id; @@ -31,108 +42,347 @@ contract Will4USNFT is ERC721URIStorage, Ownable { string metadata; // this is a pointer to json object that contains the metadata for this class } - /** - * Errors ************ - */ - error InvalidTokenId(uint256 tokenId); - /** * Events ************ */ event ItemAwarded(uint256 indexed tokenId, address indexed recipient, uint256 indexed classId); - event TokenMetadataUpdated(address indexed sender, uint256 indexed tokenId, string tokenURI); + event TokenMetadataUpdated( + address indexed sender, uint256 indexed classId, uint256 indexed tokenId, string tokenURI + ); + event ClassAdded(uint256 indexed classId, string metadata); + event UpdatedClassTokenSupply(uint256 indexed classId, uint256 supply); + event UpdatedMaxMintablePerClass(uint256 maxMintable); + event Redeemed(bytes32 indexed occurrenceId, uint256 indexed tokenId, uint256 indexed classId); /** * Modifiers ************ */ + /** + * @notice Checks if the sender is a campaign member + * @dev This modifier is used to check if the sender is a campaign member + * @param sender The sender address + */ modifier onlyCampaingnMember(address sender) { - require(campaignMembers[sender], "Only campaign members can call this function"); + if (!hasRole(MINTER_ROLE, sender)) { + revert Errors.Unauthorized(sender); + } _; } /** * Constructor ********* */ - constructor(address owner) ERC721("Will 4 US NFT Collection", "WILL4USNFT") Ownable(owner) { + constructor( + address _defaultAdmin, + address _minter, + address _pauser, + uint256 _maxMintablePerClass + ) ERC721("Will 4 US NFT Collection", "WILL4USNFT") { // add the owner to the campaign members - campaignMembers[owner] = true; + _addCampaignMember(_defaultAdmin); + + _grantRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _grantRole(PAUSER_ROLE, _pauser); + _grantRole(MINTER_ROLE, _minter); - // set the owner address - _transferOwnership(owner); + _setMaxMintablePerClass(_maxMintablePerClass); } /** * External Functions ***** */ - function addCampaignMember(address _member) external onlyOwner { - campaignMembers[_member] = true; + /** + * @notice Adds a campaign member + * @dev This function is only callable by the owner + * @param _member The member to add + */ + function addCampaignMember(address _member) external onlyRole(DEFAULT_ADMIN_ROLE) { + _addCampaignMember(_member); } - function removeCampaignMember(address _member) external onlyOwner { - campaignMembers[_member] = false; + /** + * @notice Adds a campaign member + * @dev This function is internal + * @param _member The member to add + */ + function _addCampaignMember(address _member) internal { + _grantRole(MINTER_ROLE, _member); + } + + /** + * @notice Removes a campaign member + * @dev This function is only callable by the owner + * @param _member The member to remove + */ + function removeCampaignMember(address _member) external onlyRole(DEFAULT_ADMIN_ROLE) { + revokeRole(MINTER_ROLE, _member); } /** * @notice Awards campaign nft to supporter + * @dev This function is only callable by campaign members + * @param _recipient The recipient of the item + * @param _classId The class ID */ - function awardCampaignItem(address _recipient, string memory _tokenURI, uint256 _classId) + function awardCampaignItem(address _recipient, uint256 _classId) external onlyCampaingnMember(msg.sender) returns (uint256) { - uint256 tokenId = _mintCampaingnItem(_recipient, _tokenURI, _classId); + if (mintedPerClass[_recipient][_classId] > maxMintablePerClass) { + revert Errors.MaxMintablePerClassReached(_recipient, _classId, maxMintablePerClass); + } + + uint256 tokenId = _mintCampaingnItem(_recipient, _classId); + mintedPerClass[_recipient][_classId]++; emit ItemAwarded(tokenId, _recipient, _classId); return tokenId; } - function addClass(string memory _name, string memory _description, string memory _imagePointer, uint256 _supply) + /** + * @notice Awards campaign nft to a batch of supporters + * @dev This function is only callable by campaign members + * @param _recipients The recipients of the item + * @param _classIds The class IDs + */ + function batchAwardCampaignItem(address[] memory _recipients, uint256[] memory _classIds) external onlyCampaingnMember(msg.sender) + returns (uint256[] memory) { + uint256 length = _recipients.length; + uint256[] memory tokenIds = new uint256[](length); + + for (uint256 i = 0; i < length;) { + if (mintedPerClass[_recipients[i]][_classIds[i]] > maxMintablePerClass) { + revert("You have reached the max mintable for this class"); + } + + tokenIds[i] = _mintCampaingnItem(_recipients[i], _classIds[i]); + mintedPerClass[_recipients[i]][_classIds[i]]++; + + emit ItemAwarded(tokenIds[i], _recipients[i], _classIds[i]); + + unchecked { + ++i; + } + } + + return tokenIds; + } + + /** + * @notice Redeems a campaign item + * @dev This function is only callable by campaign members + * @param _occurrenceId The occurrence ID + * @param _tokenId The token ID + */ + function redeem(bytes32 _occurrenceId, uint256 _tokenId) external onlyCampaingnMember(msg.sender) { + if (super.ownerOf(_tokenId) == address(0)) { + revert Errors.InvalidTokenId(_tokenId); + } + + if (redeemed[_occurrenceId][_tokenId]) { + revert Errors.AlreadyRedeemed(_occurrenceId, _tokenId); + } + + redeemed[_occurrenceId][_tokenId] = true; + + emit Redeemed(_occurrenceId, _tokenId, classes[_tokenId].id); + } + + /** + * @notice Adds a new class to the campaign for issuance + * @dev This function is only callable by campaign members + * @param _name The name of the class + * @param _description The description of the class + * @param _imagePointer The image pointer for the class + * @param _metadata The metadata pointer for the class + * @param _supply The total supply of the class + */ + function addClass( + string memory _name, + string memory _description, + string memory _imagePointer, + string memory _metadata, + uint256 _supply + ) external onlyCampaingnMember(msg.sender) { uint256 id = ++classIds; + totalClassesSupply += _supply; - classes[id] = - Class(id, _supply, 0, _name, _description, _imagePointer, "https://a_new_pointer_to_json_object.io"); + classes[id] = Class(id, _supply, 0, _name, _description, _imagePointer, _metadata); + + emit ClassAdded(id, _metadata); } - function updateTokenMetadata(uint256 _tokenId, string memory _tokenURI) external onlyCampaingnMember(msg.sender) { + /** + * @notice Returns all classes + */ + function getAllClasses() public view returns (Class[] memory) { + Class[] memory _classes = new Class[](classIds); + + for (uint256 i = 0; i < classIds; i++) { + _classes[i] = classes[i + 1]; + } + + return _classes; + } + + /** + * @notice Updates the token metadata + * @dev This function is only callable by campaign members - only use if you really need to + * @param _tokenId The token ID to update + * @param _classId The class ID + * @param _newTokenURI The new token URI 🚨 must be a pointer to a json object 🚨 + * @return The new token URI + */ + function updateTokenMetadata(uint256 _classId, uint256 _tokenId, string memory _newTokenURI) + external + onlyRole(DEFAULT_ADMIN_ROLE) + returns (string memory) + { if (super.ownerOf(_tokenId) != address(0)) { - _setTokenURI(_tokenId, _tokenURI); + _setTokenURI(_tokenId, _newTokenURI); + + emit TokenMetadataUpdated(msg.sender, _classId, _tokenId, tokenURI(_tokenId)); - emit TokenMetadataUpdated(msg.sender, _tokenId, _tokenURI); + return tokenURI(_tokenId); } else { - revert InvalidTokenId(_tokenId); + revert Errors.InvalidTokenId(_tokenId); + } + } + + /** + * @notice Sets the class token supply + * @dev This function is only callable by campaign members + * @param _classId The class ID + * @param _supply The new supply + */ + function setClassTokenSupply(uint256 _classId, uint256 _supply) + external + onlyCampaingnMember(msg.sender) + { + uint256 currentSupply = classes[_classId].supply; + uint256 minted = classes[_classId].minted; + + if (_supply < currentSupply) { + // if the new supply is less than the current supply, we need to check if the new supply is less than the minted + // if it is, then we need to revert + if (_supply < minted) { + revert Errors.NewSupplyTooLow(minted, _supply); + } } + + // update the total supply + totalClassesSupply = totalClassesSupply - currentSupply + _supply; + classes[_classId].supply = _supply; + + emit UpdatedClassTokenSupply(_classId, _supply); + } + + /** + * @notice Sets the max mintable per wallet + * @dev This function is only callable by campaign members + * @param _maxMintable The new max mintable + */ + function setMaxMintablePerClass(uint256 _maxMintable) + external + onlyCampaingnMember(msg.sender) + { + _setMaxMintablePerClass(_maxMintable); } /** * View Functions ****** */ - function getClassById(uint256 _id) external view returns (Class memory) { - return classes[_id]; + /** + * @notice Returns if the token has been redeemed for an occurrence + * @param _occurrenceId The occurrence ID + * @param _tokenId The token ID + * @return bool Returns true if the token has been redeemed + */ + + function getRedeemed(bytes32 _occurrenceId, uint256 _tokenId) external view returns (bool) { + return redeemed[_occurrenceId][_tokenId]; + } + + /** + * @notice Returns the total supply for a class + * @param _classId The class ID + */ + function getTotalSupplyForClass(uint256 _classId) external view returns (uint256) { + return classes[_classId].supply; + } + + /** + * @notice Returns the total supply for all classes + */ + function getTotalSupplyForAllClasses() external view returns (uint256) { + return totalClassesSupply; + } + + /** + * @notice Returns `_baseURI` for the `tokenURI` + */ + function _baseURI() internal pure override returns (string memory) { + // TODO: 🚨 update this when production ready 🚨 + return string.concat( + "https://pharo.mypinata.cloud/ipfs/QmSnzdnhtCuJ6yztHmtYFT7eU2hFF17QNM6rsNohFn6csg/" + ); + } + + /** + * @notice Returns the `tokenURI` + * @param _classId The class ID + * @param _tokenId The token ID + */ + function getTokenURI(uint256 _classId, uint256 _tokenId) public pure returns (string memory) { + string memory classId = Strings.toString(_classId); + string memory tokenId = Strings.toString(_tokenId); + + return string.concat(classId, "/", tokenId, ".json"); + } + + /** + * @notice Returns the owner of the token + * @param _tokenId The token ID + */ + function getOwnerOfToken(uint256 _tokenId) external view returns (address) { + return super.ownerOf(_tokenId); } /** * Internal Functions ****** */ - function _mintCampaingnItem(address _recipient, string memory _tokenURI, uint256 _classId) - internal - returns (uint256) - { + /** + * @notice Sets the max mintable per wallet + * @dev Intenral function to set the max mintable per wallet + * @param _maxMintable The new max mintable + */ + function _setMaxMintablePerClass(uint256 _maxMintable) internal { + maxMintablePerClass = _maxMintable; + emit UpdatedMaxMintablePerClass(_maxMintable); + } + + /** + * @notice Mints a new campaign item + * @param _recipient The recipient of the item + * @param _classId The class ID + */ + function _mintCampaingnItem(address _recipient, uint256 _classId) internal returns (uint256) { uint256 tokenId = ++_tokenIds; - mintCounter++; // update the class minted count - classes[_classId].minted = classes[_classId].minted++; + classes[_classId].minted++; _safeMint(_recipient, tokenId); - _setTokenURI(tokenId, _tokenURI); + _setTokenURI(tokenId, getTokenURI(_classId, tokenId)); return tokenId; } @@ -141,9 +391,21 @@ contract Will4USNFT is ERC721URIStorage, Ownable { * Overrides */ - function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721URIStorage) returns (bool) {} + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(AccessControl, ERC721URIStorage) + returns (bool) + { } - function tokenURI(uint256 tokenId) public view virtual override(ERC721URIStorage) returns (string memory) { + function tokenURI(uint256 tokenId) + public + view + virtual + override(ERC721URIStorage) + returns (string memory) + { return super.tokenURI(tokenId); } } diff --git a/src/interfaces/IIVOccurrenceManager.sol b/src/interfaces/IIVOccurrenceManager.sol new file mode 100644 index 0000000..2900aef --- /dev/null +++ b/src/interfaces/IIVOccurrenceManager.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import { Structs } from "../../src/library/Structs.sol"; + +/** + * @title IIVOccurrenceManager + * @notice Interface for the IVOccurrenceManager contract + * @dev We use the term occurrence to describe an event, appointment, or any other type of gathering. + * @author @codenamejason + */ +interface IIVOccurrenceManager { + function createOccurrence( + string memory name, + string memory description, + uint256 start, + uint256 end, + uint256 price, + address token, + address[] memory staff, + Structs.Metadata memory metadata + ) external returns (bytes32); + function updateOccurrence( + bytes32 occurenceId, + string memory name, + string memory description, + uint256 start, + uint256 end, + uint256 price, + address token, + address[] memory staff, + Structs.Metadata memory metadata + ) external; + function getOccurrence(bytes32 _occurenceId) + external + view + returns (Structs.Occurrence memory); + function hostOccurrence(bytes32 _occurenceId, address[] memory _attendees) external; + function recognizeOccurrence(bytes32 _occurenceId, Structs.Metadata memory _content) external; + function getStaffMemberByOccurrenceId(bytes32 _occurenceId, address _member) + external + view + returns (Structs.Staff memory); + function getStaffMembersForOccurrenceId(bytes32 _occurenceId) + external + view + returns (Structs.Staff[] memory); + function getOccurrences() external view returns (Structs.Occurrence[] memory); + function getOccurrenceById(bytes32 _occurenceIdId) + external + view + returns (Structs.Occurrence memory); +} diff --git a/src/library/Enums.sol b/src/library/Enums.sol new file mode 100644 index 0000000..be56403 --- /dev/null +++ b/src/library/Enums.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +contract Enums { + enum Status { + Pending, + Active, + Recognized, + Hosted, + Inactive, + Rejected + } +} diff --git a/src/library/Errors.sol b/src/library/Errors.sol new file mode 100644 index 0000000..b7130db --- /dev/null +++ b/src/library/Errors.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.20; + +library Errors { + error Unauthorized(address caller); + error InvalidTokenId(uint256 tokenId); + error MaxMintablePerClassReached(address recipient, uint256 classId, uint256 maxMintable); + error AlreadyRedeemed(bytes32 occurrenceId, uint256 tokenId); + error NewSupplyTooLow(uint256 minted, uint256 supply); + error OccurrenceDoesNotExist(bytes32 occurrenceId); + error NotCreator(address caller); + error ZeroAddress(); + error ZeroAmount(); + error TransferFailed(); +} diff --git a/src/library/Recover.sol b/src/library/Recover.sol new file mode 100644 index 0000000..8c07ff0 --- /dev/null +++ b/src/library/Recover.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract Recover { + function recoverERC20(address _token) external { + uint256 balance = IERC20(_token).balanceOf(address(this)); + IERC20(_token).transfer(msg.sender, balance); + } +} diff --git a/src/library/Structs.sol b/src/library/Structs.sol new file mode 100644 index 0000000..2b64340 --- /dev/null +++ b/src/library/Structs.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import { Enums } from "./Enums.sol"; + +library Structs { + struct Metadata { + /// @notice Protocol ID corresponding to a specific protocol (currently using IPFS = 1) + uint256 protocol; + /// @notice Pointer (hash) to fetch metadata for the specified protocol + string pointer; + } + + struct Staff { + // keccak256(abi.encodePacked(_eventId, _member)), + bytes32 id; + address member; + Metadata metadata; + // member address to their level (the user can have multiple levels and use how they want) + // uint256[] levels; + Enums.Status status; + } + + struct Occurrence { + // keccak256(abi.encodePacked(_name, _start, _end)) + bytes32 id; + address creator; + string name; + string description; + uint256 start; + uint256 end; + uint256 price; + address token; + Enums.Status status; + address[] staff; + Metadata metadata; + address[] attendees; + } + + struct Class { + uint256 id; + uint256 supply; // total supply of this class? do we want this? + uint256 minted; + string name; + string description; + string imagePointer; + string metadata; // this is a pointer to json object that contains the metadata for this class + } +} diff --git a/test/IVERC20BaseToken.t.sol b/test/IVERC20BaseToken.t.sol new file mode 100644 index 0000000..44b48f6 --- /dev/null +++ b/test/IVERC20BaseToken.t.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.20; + +import { Test, console2, StdUtils } from "forge-std/Test.sol"; + +import { IVERC20BaseToken } from "../src/IVERC20BaseToken.sol"; + +contract IVERC20BaseTokenTest is Test { + IVERC20BaseToken public tokenContract; + address deployerAddress; + + bytes32 public constant DEFAULT_ADMIN_ROLE = keccak256("DEFAULT_ADMIN_ROLE"); + + function setUp() public { + deployerAddress = vm.envAddress("DEPLOYER_ADDRESS"); + tokenContract = + new IVERC20BaseToken(deployerAddress, deployerAddress, deployerAddress, "TestToken", "TST"); + } + + function test_deploy() public { + assertEq(tokenContract.name(), "TestToken", "name should be TestToken"); + assertEq(tokenContract.symbol(), "TST", "symbol should be TST"); + assertEq(tokenContract.totalSupply(), 0, "totalSupply should be 0"); + assertEq(tokenContract.balanceOf(deployerAddress), 0, "balanceOf should be 0"); + assertEq( + tokenContract.hasRole(tokenContract.getRoleAdmin(DEFAULT_ADMIN_ROLE), deployerAddress), + true, + "default admin should be deployerAddress" + ); + } + + function test_mint() public { + vm.startPrank(deployerAddress); + tokenContract.mint(makeAddr("recipient1"), 10e18); + vm.stopPrank(); + + assertEq(tokenContract.totalSupply(), 10e18, "totalSupply should be 100"); + assertEq(tokenContract.balanceOf(makeAddr("recipient1")), 10e18, "balanceOf should be 100"); + } + + function test_revert_mint() public { + vm.startPrank(makeAddr("chad")); + vm.expectRevert(); + tokenContract.mint(makeAddr("recipient1"), 10e18); + vm.stopPrank(); + } + + function test_pause() public { + vm.startPrank(deployerAddress); + tokenContract.pause(); + vm.stopPrank(); + + assertEq(tokenContract.paused(), true, "paused should be true"); + } + + function test_revert_pause() public { + vm.startPrank(makeAddr("chad")); + vm.expectRevert(); + tokenContract.pause(); + vm.stopPrank(); + } + + function test_unpause() public { + vm.startPrank(deployerAddress); + tokenContract.pause(); + tokenContract.unpause(); + vm.stopPrank(); + + assertEq(tokenContract.paused(), false, "paused should be false"); + } + + function test_revert_unpause() public { + vm.prank(deployerAddress); + tokenContract.pause(); + vm.startPrank(makeAddr("chad")); + vm.expectRevert(); + + tokenContract.unpause(); + vm.stopPrank(); + } + + function test_burn() public { + vm.startPrank(deployerAddress); + tokenContract.mint(deployerAddress, 10e18); + tokenContract.burn(10e18); + vm.stopPrank(); + + assertEq(tokenContract.totalSupply(), 0, "totalSupply should be 0"); + assertEq(tokenContract.balanceOf(deployerAddress), 0, "balanceOf should be 0"); + } + + function test_revert_burn() public { + vm.startPrank(makeAddr("chad")); + vm.expectRevert(); + tokenContract.burn(10e18); + vm.stopPrank(); + } + + function test_revert_transfer() public { + vm.startPrank(makeAddr("chad")); + vm.expectRevert(); + tokenContract.transfer(makeAddr("recipient1"), 10e18); + vm.stopPrank(); + } + + function test_revert_transferFrom() public { + vm.startPrank(makeAddr("chad")); + vm.expectRevert(); + tokenContract.transferFrom(makeAddr("sender1"), makeAddr("recipient1"), 10e18); + vm.stopPrank(); + } +} diff --git a/test/IVERC20TokenContractFactory.t.sol b/test/IVERC20TokenContractFactory.t.sol new file mode 100644 index 0000000..fc21ed0 --- /dev/null +++ b/test/IVERC20TokenContractFactory.t.sol @@ -0,0 +1,65 @@ +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; + +import { IVERC20TokenContractFactory } from "../src/IVERC20TokenContractFactory.sol"; +import { MockIVERC20Token } from "./mocks/MockIVERC20Token.sol"; +import { IVERC20BaseToken } from "../src/IVERC20BaseToken.sol"; +import { Errors } from "../src/library/Errors.sol"; + +contract IVERC20TokenContractFactoryTest is Test { + IVERC20TokenContractFactory factoryInstance; + address public deployerAddress; + IVERC20BaseToken public ivBaseToken; + + uint256 private _nonces; + + function setUp() public { + deployerAddress = makeAddr("deployerAddress"); + factoryInstance = new IVERC20TokenContractFactory(); + factoryInstance.setDeployer(deployerAddress, true); + + _nonces = 0; + } + + function test_constructor() public { + assertTrue(factoryInstance.isDeployer(address(this))); + assertTrue(factoryInstance.isDeployer(deployerAddress)); + } + + function test_deploy_shit() public { + address deployedAddress = factoryInstance.create( + deployerAddress, deployerAddress, deployerAddress, "TestToken", "TST" + ); + + assertNotEq(deployedAddress, address(0)); + vm.startPrank(deployerAddress); + MockIVERC20Token(deployedAddress).mint(deployerAddress, 100e18); + assertEq(MockIVERC20Token(deployedAddress).balanceOf(deployerAddress), 100e18); + vm.stopPrank(); + } + + function testRevert_deploy_UNAUTHORIZED() public { + vm.expectRevert(); + vm.prank(makeAddr("alice")); + factoryInstance.create( + deployerAddress, deployerAddress, deployerAddress, "TestToken", "TST" + ); + } + + function test_setDeployer() public { + address newContractFactoryAddress = makeAddr("bob"); + + assertFalse(factoryInstance.isDeployer(newContractFactoryAddress)); + factoryInstance.setDeployer(newContractFactoryAddress, true); + assertTrue(factoryInstance.isDeployer(newContractFactoryAddress)); + } + + function testRevert_setDeployer_UNAUTHORIZED() public { + address newContractFactoryAddress = makeAddr("bob"); + + vm.expectRevert(); + vm.prank(makeAddr("alice")); + factoryInstance.setDeployer(newContractFactoryAddress, true); + } +} diff --git a/test/IVERC721BaseToken.t.sol b/test/IVERC721BaseToken.t.sol new file mode 100644 index 0000000..44828ec --- /dev/null +++ b/test/IVERC721BaseToken.t.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import { Test, console2, StdUtils } from "forge-std/Test.sol"; + +import { IVERC721BaseToken } from "../src/IVERC721BaseToken.sol"; + +contract IVERC721BaseTokenTest is Test { + address deployerAddress; + IVERC721BaseToken public tokenContract; + + function setUp() public { + deployerAddress = vm.envAddress("DEPLOYER_ADDRESS"); + tokenContract = new IVERC721BaseToken( + deployerAddress, + deployerAddress, + deployerAddress, + "TestToken NFT", + "TST" + ); + } + + function test_deploy() public { + assertEq(tokenContract.name(), "TestToken NFT", "name should be TestToken NFT"); + assertEq(tokenContract.symbol(), "TST", "symbol should be TST"); + assertEq(tokenContract.totalSupply(), 0, "totalSupply should be 0"); + assertEq(tokenContract.balanceOf(deployerAddress), 0, "balanceOf should be 0"); + } + + function test_mint() public { + vm.startPrank(deployerAddress); + tokenContract.addClass( + "Volunteer", "Test volunteer class", "https://yourpointer", "", 500000 + ); + tokenContract.awardItem(makeAddr("recipient1"), 1); + vm.stopPrank(); + + assertEq(tokenContract.totalSupply(), 1, "totalSupply should be 1"); + assertEq(tokenContract.balanceOf(makeAddr("recipient1")), 1, "balanceOf should be 1"); + } +} diff --git a/test/IVERC721TokenContractFactory.t.sol b/test/IVERC721TokenContractFactory.t.sol new file mode 100644 index 0000000..3c2597c --- /dev/null +++ b/test/IVERC721TokenContractFactory.t.sol @@ -0,0 +1,66 @@ +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; + +import { IVERC721TokenContractFactory } from "../src/IVERC721TokenContractFactory.sol"; +import { MockIVERC721Token } from "./mocks/MockIVERC721Token.sol"; +import { IVERC20BaseToken } from "../src/IVERC20BaseToken.sol"; +import { Errors } from "../src/library/Errors.sol"; + +contract IVERC721TokenContractFactoryTest is Test { + IVERC721TokenContractFactory factoryInstance; + address public deployerAddress; + IVERC20BaseToken public ivBaseToken; + + uint256 private _nonces; + + function setUp() public { + deployerAddress = makeAddr("deployerAddress"); + factoryInstance = new IVERC721TokenContractFactory(); + factoryInstance.setDeployer(deployerAddress, true); + + _nonces = 0; + } + + function test_constructor() public { + assertTrue(factoryInstance.isDeployer(address(this))); + assertTrue(factoryInstance.isDeployer(deployerAddress)); + } + + function test_deploy_shit() public { + vm.startPrank(deployerAddress); + address deployedAddress = factoryInstance.create( + deployerAddress, deployerAddress, deployerAddress, "TestToken", "TST" + ); + + assertNotEq(deployedAddress, address(0)); + + MockIVERC721Token(deployedAddress).awardItem(deployerAddress, 1); + assertEq(MockIVERC721Token(deployedAddress).balanceOf(deployerAddress), 1); + vm.stopPrank(); + } + + function testRevert_deploy_UNAUTHORIZED() public { + vm.expectRevert(); + vm.prank(makeAddr("alice")); + factoryInstance.create( + deployerAddress, deployerAddress, deployerAddress, "TestToken", "TST" + ); + } + + function test_setDeployer() public { + address newContractFactoryAddress = makeAddr("bob"); + + assertFalse(factoryInstance.isDeployer(newContractFactoryAddress)); + factoryInstance.setDeployer(newContractFactoryAddress, true); + assertTrue(factoryInstance.isDeployer(newContractFactoryAddress)); + } + + function testRevert_setDeployer_UNAUTHORIZED() public { + address newContractFactoryAddress = makeAddr("bob"); + + vm.expectRevert(); + vm.prank(makeAddr("alice")); + factoryInstance.setDeployer(newContractFactoryAddress, true); + } +} diff --git a/test/IVOccurrenceManager.t.sol b/test/IVOccurrenceManager.t.sol new file mode 100644 index 0000000..b873d58 --- /dev/null +++ b/test/IVOccurrenceManager.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.20; + +import { Test, console2, StdUtils } from "forge-std/Test.sol"; + +import { IVOccurrenceManager } from "../src/IVOccurrenceManager.sol"; +import { Enums } from "../src/library/Enums.sol"; +import { Structs } from "../src/library/Structs.sol"; + +contract IVOccurrenceManagerTest is Test { + IVOccurrenceManager ivOccurrenceManager; + + function setUp() public { + address admin = makeAddr("admin"); + ivOccurrenceManager = new IVOccurrenceManager(admin); + // ivOccurrenceManager.addStaffMember(_member, _metadata); + } + + function test_CreateOccurrence() public { + address creator = makeAddr("creator"); + address[] memory staff = new address[](1); + staff[0] = makeAddr("staff"); + vm.prank(creator); + bytes32 occurrence = ivOccurrenceManager.createOccurrence( + "name", + "description", + 1, + 2, + 3, + // todo: add mock + address(makeAddr("token")), + staff, + Structs.Metadata({ protocol: 1, pointer: "0x230847695gbv2-3" }) + ); + + (bytes32 _occurrence,, string memory name, string memory description, uint256 start,,,,,) = + ivOccurrenceManager.occurrences(occurrence); + + assertEq(_occurrence, occurrence); + assertEq(name, "name"); + assertEq(description, "description"); + assertEq(start, 1); + } +} diff --git a/test/PersonaAPIConsumer.t.sol b/test/PersonaAPIConsumer.t.sol new file mode 100644 index 0000000..65305df --- /dev/null +++ b/test/PersonaAPIConsumer.t.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.20; + +import { Test, console2 } from "forge-std/Test.sol"; +import { Will4USNFT } from "../src/Will4USNFT.sol"; + +/// @notice This contract is used to test the Will4USNFT contract +contract PersonaAPIConsumer is Test { + function setUp() public { } +} diff --git a/test/Will4USNFTDeployTest.t.sol b/test/Will4USNFTDeployTest.t.sol new file mode 100644 index 0000000..a4577aa --- /dev/null +++ b/test/Will4USNFTDeployTest.t.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.20; + +import { Test, console2 } from "forge-std/Test.sol"; +import { Will4USNFT } from "../src/Will4USNFT.sol"; + +/// @notice This contract is used to test the Will4USNFT contract +contract Will4USNFTDeployTest is Test { + Will4USNFT public nftContract; + address deployerAddress; + + bytes32 public constant DEFAULT_ADMIN_ROLE = keccak256("DEFAULT_ADMIN_ROLE"); + + event ItemAwarded(uint256 indexed tokenId, address indexed recipient, uint256 indexed classId); + event TokenMetadataUpdated( + address indexed sender, uint256 indexed classId, uint256 indexed tokenId, string tokenURI + ); + event CampaignMemberAdded(address indexed member); + event CampaignMemberRemoved(address indexed member); + event ClassAdded(uint256 indexed classId, string metadata); + + function setUp() public { + deployerAddress = vm.envAddress("DEPLOYER_ADDRESS"); + nftContract = new Will4USNFT(deployerAddress, deployerAddress, deployerAddress, 5); + // string memory url = vm.rpcUrl("arbitrumGoerli"); + // assertEq(url, "https://arb-goerli.g.alchemy.com/v2/RqTiyvS7OspxaAQUQupKKCTjmf94JL-I"); + } + + // todo: test that the contract is deployed with the correct parameters + function test_deploy() public { + assertEq(nftContract.maxMintablePerClass(), 5, "maxMintablePerClass should be 5"); + assertEq(nftContract.totalClassesSupply(), 0, "totalSupply should be 0"); + assertEq(nftContract.balanceOf(deployerAddress), 0, "balanceOf should be 0"); + assertEq( + nftContract.hasRole(nftContract.getRoleAdmin(DEFAULT_ADMIN_ROLE), deployerAddress), + true, + "default admin should be deployerAddress" + ); + } +} diff --git a/test/Will4USNFTTest.t.sol b/test/Will4USNFTTest.t.sol index ef68852..7502e9a 100644 --- a/test/Will4USNFTTest.t.sol +++ b/test/Will4USNFTTest.t.sol @@ -1,48 +1,243 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.20; -import {Test, console2} from "forge-std/Test.sol"; -import {Will4USNFT} from "../src/Will4USNFT.sol"; +import { Test, console2, StdUtils } from "forge-std/Test.sol"; +import { Will4USNFT } from "../src/Will4USNFT.sol"; /// @notice This contract is used to test the Will4USNFT contract contract Will4USNFTTest is Test { + bytes32 public constant DEFAULT_ADMIN_ROLE = keccak256("DEFAULT_ADMIN_ROLE"); + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + Will4USNFT public nftContract; address deployerAddress; + error InvalidTokenId(uint256 tokenId); + error MaxMintablePerClassReached(address recipient, uint256 classId, uint256 maxMintable); + error AlreadyRedeemed(address redeemer, uint256 tokenId); + error Unauthorized(address sender); + error NewSupplyTooLow(uint256 minted, uint256 supply); + event ItemAwarded(uint256 indexed tokenId, address indexed recipient, uint256 indexed classId); + event TokenMetadataUpdated( + address indexed sender, uint256 indexed classId, uint256 indexed tokenId, string tokenURI + ); + event CampaignMemberAdded(address indexed member); + event CampaignMemberRemoved(address indexed member); + event ClassAdded(uint256 indexed classId, string metadata); + event Redeemed(bytes32 indexed occurrenceId, uint256 indexed tokenId, uint256 indexed classId); + event RoleGranted(bytes32 indexed role, address indexed account, address sender); function setUp() public { deployerAddress = vm.envAddress("DEPLOYER_ADDRESS"); - nftContract = new Will4USNFT(deployerAddress); + nftContract = new Will4USNFT(deployerAddress, deployerAddress, deployerAddress, 5); + + vm.startPrank(deployerAddress); + nftContract.addCampaignMember(deployerAddress); + nftContract.addClass( + "name", "description", "imagePointer", "https://a_new_pointer_to_json_object.io", 1e7 + ); + nftContract.awardCampaignItem(makeAddr("recipient1"), 1); + vm.stopPrank(); } function test_awardCampaignItem() public { vm.startPrank(deployerAddress); vm.expectEmit(true, true, true, true); - emit ItemAwarded(1, makeAddr("recipient1"), 1); - uint256 tokenId = nftContract.awardCampaignItem(makeAddr("recipient1"), "https://placeholder.com/1", 1); + emit ItemAwarded(2, makeAddr("recipient1"), 1); + uint256 tokenId1 = nftContract.awardCampaignItem(makeAddr("recipient1"), 1); + + // mint a second token + vm.expectEmit(true, true, true, true); + emit ItemAwarded(3, makeAddr("recipient1"), 1); + uint256 tokenId2 = nftContract.awardCampaignItem(makeAddr("recipient1"), 1); + + vm.stopPrank(); + assertEq(tokenId1, 2, "Token Id should be 2"); + assertEq(tokenId2, 3, "Token Id should be 3"); + } + + function test_revert_awardCampaignItem_maxMintablePerClass() public { + vm.startPrank(deployerAddress); + nftContract.awardCampaignItem(makeAddr("recipient1"), 1); + nftContract.awardCampaignItem(makeAddr("recipient1"), 1); + nftContract.awardCampaignItem(makeAddr("recipient1"), 1); + nftContract.awardCampaignItem(makeAddr("recipient1"), 1); + nftContract.awardCampaignItem(makeAddr("recipient1"), 1); + vm.expectRevert(); + nftContract.awardCampaignItem(makeAddr("recipient1"), 1); + + vm.stopPrank(); + } + + function test_batchAwardCampaignItem() public { + vm.startPrank(deployerAddress); + address[] memory recipients = new address[](2); + recipients[0] = makeAddr("recipient1"); + recipients[1] = makeAddr("recipient2"); + string[] memory tokenURIs = new string[](2); + uint256[] memory classIds = new uint256[](2); + classIds[0] = 1; + classIds[1] = 1; + + nftContract.batchAwardCampaignItem(recipients, classIds); + + vm.stopPrank(); + } + + function test_updateTokenMetadata() public { + vm.startPrank(deployerAddress); + vm.expectEmit(true, true, true, true); + emit TokenMetadataUpdated( + deployerAddress, + 1, + 1, + "https://pharo.mypinata.cloud/ipfs/QmSnzdnhtCuJ6yztHmtYFT7eU2hFF17QNM6rsNohFn6csg/2/1.json" + ); + nftContract.updateTokenMetadata(1, 1, "2/1.json"); + + vm.stopPrank(); + assertEq( + nftContract.tokenURI(1), + "https://pharo.mypinata.cloud/ipfs/QmSnzdnhtCuJ6yztHmtYFT7eU2hFF17QNM6rsNohFn6csg/2/1.json" + ); + } + + function test_addCampaignMember() public { + vm.startPrank(deployerAddress); + nftContract.addCampaignMember(makeAddr("member1")); + + vm.stopPrank(); + assertEq( + nftContract.hasRole(MINTER_ROLE, makeAddr("member1")), + true, + "Member should have MINTER_ROLE" + ); + } + + function test_removeCampaignMember() public { + vm.startPrank(deployerAddress); + + nftContract.removeCampaignMember(makeAddr("member1")); + vm.stopPrank(); + assertEq( + nftContract.hasRole(MINTER_ROLE, makeAddr("member1")), + false, + "Member should NOT have MINTER_ROLE" + ); + } + + function test_redeem() public { + vm.startPrank(deployerAddress); + vm.expectEmit(true, true, true, true); + emit Redeemed("0x01", 1, 1); + + nftContract.redeem("0x01", 1); + } + + function test_revert_redeem_AlreadyRedeemed() public { + vm.startPrank(deployerAddress); + nftContract.redeem("0x01", 1); + vm.expectRevert(); + + nftContract.redeem("0x01", 1); vm.stopPrank(); + } - assertEq(tokenId, 1, "Token Id should be 1"); + function test_revert_redeem_Unauthorized() public { + vm.startPrank(makeAddr("chad")); + vm.expectRevert(); + + nftContract.redeem("0x01", 1); + vm.stopPrank(); } - function test_UpdateMetadata() public {} + function test_addClass() public { + vm.startPrank(deployerAddress); + vm.expectEmit(true, true, true, true); + emit ClassAdded(2, "https://a_new_pointer_to_json_object.io"); + nftContract.addClass( + "name2", "description", "imagePointer", "https://a_new_pointer_to_json_object.io", 1e7 + ); - function test_addCampaignMember(address _member) public {} + vm.stopPrank(); + ( + uint256 id, + uint256 supply, + uint256 minted, + string memory name, + string memory description, + string memory imagePointer, + string memory metadataPointer + ) = nftContract.classes(2); + + assertEq(name, "name2", "Class name should be name"); + assertEq(description, "description", "Class description should be description"); + assertEq(imagePointer, "imagePointer", "Class imagePointer should be imagePointer"); + assertEq( + metadataPointer, + "https://a_new_pointer_to_json_object.io", + "Class metadataPointer should be metadataPointer" + ); + assertEq(supply, 1e7, "Class supply should be 1e7"); + assertEq(minted, 0, "Class minted should be 0"); + assertEq(id, 2, "Class id should be 2"); + } + + function test_revert_addClass_Unauthorized() public { + vm.prank(makeAddr("chad")); + vm.expectRevert(); + nftContract.addClass( + "name2", "description", "imagePointer", "https://a_new_pointer_to_json_object.io", 1e7 + ); + } + + function test_getTotalSupplyForClass() public { + assertEq(nftContract.getTotalSupplyForClass(1), 1e7, "Total supply should be 1e7"); + } + + function test_setClassTokenSupply() public { + vm.prank(deployerAddress); + nftContract.setClassTokenSupply(1, 1e10); - function test_removeCampaignMember(address _member) public {} - - function test_awardCampaignItem(address _recipient, string memory _tokenURI, uint256 _classId) public {} + (, uint256 supply,,,,,) = nftContract.classes(1); + assertEq(supply, 1e10, "Total supply should be 1e10"); + } - function test_addClass(string memory _name, string memory _description, string memory _imagePointer, uint256 _supply) public {} + function test_revert_setClassTokenSupply_NewSupplyTooLow() public { + vm.startPrank(deployerAddress); + nftContract.setClassTokenSupply(1, 10); + nftContract.awardCampaignItem(makeAddr("recipient1"), 1); + nftContract.awardCampaignItem(makeAddr("recipient2"), 1); + nftContract.awardCampaignItem(makeAddr("recipient3"), 1); + nftContract.awardCampaignItem(makeAddr("recipient4"), 1); + nftContract.awardCampaignItem(makeAddr("recipient5"), 1); + vm.expectRevert(); + nftContract.setClassTokenSupply(1, 5); + vm.stopPrank(); + } - function test_updateTokenMetadata(uint256 _tokenId, string memory _tokenURI) public {} + function test_revert_setClassTokenSupply_Unauthorized() public { + vm.prank(makeAddr("chad")); + vm.expectRevert(); + nftContract.setClassTokenSupply(1, 1e10); + } - function test_getClassById(uint256 _id) public {} + function test_getTotalSupplyForAllClasses() public { + assertEq(nftContract.getTotalSupplyForAllClasses(), 1e7, "Total supply should be 1e7"); + } - function test__mintCampaingnItem(address _recipient, string memory _tokenURI, uint256 _classId) public {} + function test_setMaxMintablePerClass() public { + vm.prank(deployerAddress); + nftContract.setMaxMintablePerClass(10); - function test_supportsInterface(bytes4 interfaceId) public {} + assertEq(nftContract.maxMintablePerClass(), 10, "Total supply should be 1e10"); + } - function test_tokenURI(uint256 tokenId) public {} + function test_revert_setMaxMintablePerClass_Unauthorized() public { + vm.prank(makeAddr("chad")); + vm.expectRevert(); + nftContract.setMaxMintablePerClass(10); + } } diff --git a/test/mocks/MockIVERC20Token.sol b/test/mocks/MockIVERC20Token.sol new file mode 100644 index 0000000..adf96a3 --- /dev/null +++ b/test/mocks/MockIVERC20Token.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import { IVERC20BaseToken } from "../../src/IVERC20BaseToken.sol"; + +contract MockIVERC20Token is IVERC20BaseToken { + constructor( + address defaultAdmin, + address minter, + address pauser, + string memory name, + string memory symbol + ) IVERC20BaseToken(defaultAdmin, minter, pauser, name, symbol) { } +} diff --git a/test/mocks/MockIVERC721Token.sol b/test/mocks/MockIVERC721Token.sol new file mode 100644 index 0000000..55a229c --- /dev/null +++ b/test/mocks/MockIVERC721Token.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import { IVERC721BaseToken } from "../../src/IVERC721BaseToken.sol"; + +contract MockIVERC721Token is IVERC721BaseToken { + constructor( + address defaultAdmin, + address minter, + address pauser, + string memory name, + string memory symbol + ) IVERC721BaseToken(defaultAdmin, minter, pauser, name, symbol) { } +}