diff --git a/script/MembershipManager.s.sol b/script/MembershipManager.s.sol new file mode 100644 index 0000000..a978c92 --- /dev/null +++ b/script/MembershipManager.s.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.15; + +import {Script} from "forge-std/Script.sol"; +import {AccessToken} from "../src/AccessToken.sol"; +import {MembershipNFT} from "../src/MembershipNFT.sol"; +import {MembershipManager} from "../src/MembershipManager.sol"; + +contract deployAccessToken is Script { + function run() external returns(AccessToken, MembershipNFT, MembershipManager) { + vm.startBroadcast(); + AccessToken accessToken = new AccessToken(); + MembershipNFT membershipNFT = new MembershipNFT(); + MembershipManager membershipManager = new MembershipManager(address(accessToken), address(membershipNFT), address(accessToken.getOwner())); + vm.stopBroadcast(); + + return (accessToken, membershipNFT, membershipManager); + } +} \ No newline at end of file diff --git a/src/AccessToken.sol b/src/AccessToken.sol new file mode 100644 index 0000000..3e657f2 --- /dev/null +++ b/src/AccessToken.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.15; + +import {ERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +contract AccessToken is ERC20{ + + address private owner; + + modifier onlyOwner { + require(msg.sender == owner, "Unauthorized User"); + _; + } + + constructor() ERC20("AccessToken", "ACS"){ + owner = msg.sender; + } + + function mint(address to, uint256 _amount) onlyOwner external { + _mint(to, _amount); + } + + function getOwner() external view returns(address) { + return owner; + } +} \ No newline at end of file diff --git a/src/MembershipManager.sol b/src/MembershipManager.sol new file mode 100644 index 0000000..0ac0452 --- /dev/null +++ b/src/MembershipManager.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// This is a Token-Gated Membership System where users gain access to exclusive features by holding a minimum amount of ERC20 tokens and are awarded ERC721 membership NFTs for elite participation. + +pragma solidity ^0.8.15; + +// Import contracts for Token and NFT +import {MembershipNFT} from "src/MembershipNFT.sol"; +import {AccessToken} from "src/AccessToken.sol"; + +contract MembershipManager { + AccessToken public _accessToken; + MembershipNFT public _membershipNFT; + + // State variables + address public owner; + uint256 public MAX_AMOUNT = 50; + uint256 public minAmount; + + // Custom errors + error AmountTooHigh(); + error InsufficientBalance(); + error UnauthorizedAccess(); + error UnauthorizedClaim(); + + constructor(address _deployedAccessToken, address _deployedNFT, address _owner) { + _accessToken = AccessToken(_deployedAccessToken); + _membershipNFT = MembershipNFT(_deployedNFT); + owner = _owner; + } + + // Mapping for balances of Tokens and if a user has claimed NFT prior + mapping (address => uint256) public balances; + mapping (address => bool) public hasClaimed; + + function setMinimumThreshold(uint256 _newMinAmount) external { + if (msg.sender != _accessToken.getOwner()) { revert UnauthorizedAccess();} + minAmount = _newMinAmount; + } + + function getAccessToken(uint256 amount) external { + if (amount > MAX_AMOUNT) {revert AmountTooHigh();} + + _accessToken.transfer(msg.sender, amount); + balances[msg.sender] += amount; + } + + function claimMembershipNFT() external returns(uint256) { + if (_accessToken.balanceOf(msg.sender) < MAX_AMOUNT) {revert InsufficientBalance();} + if (hasClaimed[msg.sender] == true) { revert UnauthorizedClaim();} + hasClaimed[msg.sender] = true; + + uint256 tokenId = _membershipNFT.mint(msg.sender); + return tokenId; + } +} + diff --git a/src/MembershipNFT.sol b/src/MembershipNFT.sol new file mode 100644 index 0000000..e3cfff3 --- /dev/null +++ b/src/MembershipNFT.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.15; + +import "../../node_modules/@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +contract MembershipNFT is ERC721{ + uint256 public tokenID; + + mapping (uint256 => address) public NFTowners; + constructor() ERC721("AccessNFT", "ANT"){} + + function mint(address recipient) external returns(uint256){ + tokenID++; + uint256 tokenId = tokenID; + + NFTowners[tokenId] = msg.sender; + _mint(recipient, tokenId); + return tokenId; + } +} \ No newline at end of file diff --git a/test/MembershipManager.t.sol b/test/MembershipManager.t.sol new file mode 100644 index 0000000..efa8221 --- /dev/null +++ b/test/MembershipManager.t.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.15; + +import {Test, console} from "forge-std/Test.sol"; +import {MembershipManager} from "../src/MembershipManager.sol"; +import {AccessToken} from "../src/AccessToken.sol"; +import {MembershipNFT} from "../src/MembershipNFT.sol"; + +contract TokenGateTest is Test { + uint256 MINIMUM_BALANCE = 10; + address meso = makeAddr("meso"); + MembershipManager public membershipManager; + AccessToken public accessToken; + MembershipNFT public membershipNFT; + + address account1 = address(0x345); + address account2 = address(0x678); + + // Custom errors + error AmountTooHigh(); + error InsufficientBalance(); + error UnauthorizedAccess(); + error UnauthorizedClaim(); + + function setUp() public { + accessToken = new AccessToken(); + membershipNFT = new MembershipNFT(); + + membershipManager = new MembershipManager(address(accessToken), address(membershipNFT), address(membershipManager)); + accessToken.mint(address(membershipManager), 1000); + } + + function test_Deployment() public view { + assertEq(accessToken.balanceOf(address(membershipManager)), 1000); + assertEq(accessToken.name(), "AccessToken"); + assertEq(accessToken.symbol(), "ACS"); + assertEq(accessToken.getOwner(), address(this)); + assertEq(membershipNFT.name(), "AccessNFT"); + assertEq(membershipNFT.symbol(), "ANT"); + } + + function test_Revert_If_Unauthorized_Minter() public { + vm.prank(account1); + vm.expectRevert(); + accessToken.mint(account2, 1000); + } + + function test_OnlyOwner_Can_Call_MinimumThreshold() public { + vm.prank(account1); + vm.expectRevert(UnauthorizedAccess.selector); + membershipManager.setMinimumThreshold(200); + } + + function test_Successfully_SetMinimumThreshold () public { + vm.prank(address(membershipManager)); + assertEq(membershipManager.minAmount(), 0); + membershipManager.setMinimumThreshold(100); + assertEq(membershipManager.minAmount(), 100); + } + + function test_Maximum_AccessToken_Amount() public { + assertEq(accessToken.balanceOf(account1), 0); + vm.startPrank(account1); + vm.expectRevert(AmountTooHigh.selector); + membershipManager.getAccessToken(100); + vm.stopPrank(); + } + + function test_AccessToken_Transfer_Successfully () public { + assertEq(accessToken.balanceOf(account1), 0); + + vm.startPrank(account1); + membershipManager.getAccessToken(50); + uint256 currentBalance = accessToken.balanceOf(account1); + assertEq(currentBalance, 50); + console.log(currentBalance); + assertEq(membershipManager.balances(account1), 50); + vm.stopPrank(); + } + + function test_Revert_If_CallerBalance_Is_Insufficient_To_MintNFT() public { + vm.startPrank(account1); + membershipManager.getAccessToken(30); + uint256 currentBalance = accessToken.balanceOf(account1); + assertEq(currentBalance, 30); + assertEq(membershipManager.balances(account1), 30); + + vm.expectRevert(InsufficientBalance.selector); + membershipManager.claimMembershipNFT(); + vm.stopPrank(); + } + + function test_If_Caller_Has_Claimed_NFT_Before() public { + vm.startPrank(account1); + membershipManager.getAccessToken(50); + uint256 currentBalance = accessToken.balanceOf(account1); + assertEq(currentBalance, 50); + assertEq(membershipManager.balances(account1), 50); + + assertEq(membershipManager.hasClaimed(account1), false); + membershipManager.claimMembershipNFT(); + + vm.expectRevert(UnauthorizedClaim.selector); + membershipManager.claimMembershipNFT(); + + vm.stopPrank(); + } + + function test_If_User_Claimed_NFT_Successfully() public { + vm.startPrank(account1); + membershipManager.getAccessToken(50); + uint256 currentBalance = accessToken.balanceOf(account1); + assertEq(currentBalance, 50); + assertEq(membershipManager.balances(account1), 50); + + assertEq(membershipManager.hasClaimed(account1), false); + + uint256 mintedTokenId = membershipManager.claimMembershipNFT(); + + assertEq(mintedTokenId, 1); + assertEq(membershipNFT.ownerOf(mintedTokenId), account1); + vm.stopPrank(); + } +} \ No newline at end of file