diff --git a/src/token/ExistingCommunityToken.sol b/src/token/ExistingCommunityToken.sol new file mode 100644 index 0000000..7044d2b --- /dev/null +++ b/src/token/ExistingCommunityToken.sol @@ -0,0 +1,504 @@ +// SPDX-License-Identifier: MIT + +/* + _ ΞΞΞΞ _ + /_;-.__ / _\ _.-;_\ + `-._`'`_/'`.-' + `\ /` + | / + /-.( + \_._\ + \ \`; + > |/ + / // + |// + \(\ + `` + defijesus.eth +*/ + +pragma solidity 0.8.16; + +import { UUPS } from "../lib/proxy/UUPS.sol"; +import { ReentrancyGuard } from "../lib/utils/ReentrancyGuard.sol"; +import { ERC721Votes } from "../lib/token/ERC721Votes.sol"; +import { ERC721 } from "../lib/token/ERC721.sol"; +import { Ownable } from "../lib/utils/Ownable.sol"; + +import { ExistingCommunityStorage } from "./storage/ExistingCommunityStorage.sol"; +import { IBaseMetadata } from "./metadata/interfaces/IBaseMetadata.sol"; +import { IManager } from "../manager/IManager.sol"; +import { IAuction } from "../auction/IAuction.sol"; +import { IToken } from "./IToken.sol"; + +import { MerkleProof } from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; + +/// @title Token +/// @author Rohan Kulkarni & remixed by defijesus.eth +/// @notice A DAO's ERC-721 governance token +contract ExistingCommunityToken is IToken, UUPS, Ownable, ReentrancyGuard, ERC721Votes, ExistingCommunityStorage { + /// /// + /// IMMUTABLES /// + /// /// + + /// @notice The contract upgrade manager + IManager private immutable manager; + + /// /// + /// CONSTRUCTOR /// + /// /// + + /// @param _manager The contract upgrade manager address + constructor(address _manager) payable initializer { + manager = IManager(_manager); + } + + /// /// + /// INITIALIZER /// + /// /// + + /// @notice Initializes a DAO's ERC-721 token contract + /// @param _founders The DAO founders + /// @param _initStrings The encoded token and metadata initialization strings + /// @param _metadataRenderer The token's metadata renderer + /// @param _auction The token's auction house + /// @param _initialOwner The initial owner of the token + function initialize( + IManager.FounderParams[] calldata _founders, + bytes calldata _initStrings, + address _metadataRenderer, + address _auction, + address _initialOwner + ) external initializer { + // Ensure the caller is the contract manager + if (msg.sender != address(manager)) { + revert ONLY_MANAGER(); + } + + // Initialize the reentrancy guard + __ReentrancyGuard_init(); + + // Setup ownable + __Ownable_init(_initialOwner); + + // Store the founders and compute their allocations + _addFounders(_founders); + + // Decode the token name and symbol + (string memory _name, string memory _symbol, , , , ) = abi.decode(_initStrings, (string, string, string, string, string, string)); + + // Initialize the ERC-721 token + __ERC721_init(_name, _symbol); + + // Store the metadata renderer and auction house + settings.metadataRenderer = IBaseMetadata(_metadataRenderer); + settings.auction = _auction; + } + + /// @notice Called by the auction upon the first unpause / token mint to transfer ownership from founder to treasury + /// @dev Only callable by the auction contract + function onFirstAuctionStarted() external override { + if (msg.sender != settings.auction) { + revert ONLY_AUCTION(); + } + + manager.getAddresses(address(this)); + + // Force transfer ownership to the treasury + _transferOwnership(IAuction(settings.auction).treasury()); + } + + /// @notice Called upon initialization to add founders and compute their vesting allocations + /// @dev We do this by reserving an mapping of [0-100] token indices, such that if a new token mint ID % 100 is reserved, it's sent to the appropriate founder. + /// @param _founders The list of DAO founders + function _addFounders(IManager.FounderParams[] calldata _founders) internal { + // Cache the number of founders + uint256 numFounders = _founders.length; + + // Used to store the total percent ownership among the founders + uint256 totalOwnership; + + unchecked { + // For each founder: + for (uint256 i; i < numFounders; ++i) { + // Cache the percent ownership + uint256 founderPct = _founders[i].ownershipPct; + + // Continue if no ownership is specified + if (founderPct == 0) { + continue; + } + + // Update the total ownership and ensure it's valid + totalOwnership += founderPct; + + // Check that founders own less than 100% of tokens + if (totalOwnership > 99) { + revert INVALID_FOUNDER_OWNERSHIP(); + } + + // Compute the founder's id + uint256 founderId = settings.numFounders++; + + // Get the pointer to store the founder + Founder storage newFounder = founder[founderId]; + + // Store the founder's vesting details + newFounder.wallet = _founders[i].wallet; + newFounder.vestExpiry = uint32(_founders[i].vestExpiry); + // Total ownership cannot be above 100 so this fits safely in uint8 + newFounder.ownershipPct = uint8(founderPct); + + // Compute the vesting schedule + uint256 schedule = 100 / founderPct; + + // Used to store the base token id the founder will recieve + uint256 baseTokenId; + + // For each token to vest: + for (uint256 j; j < founderPct; ++j) { + // Get the available token id + baseTokenId = _getNextTokenId(baseTokenId); + + // Store the founder as the recipient + tokenRecipient[baseTokenId] = newFounder; + + emit MintScheduled(baseTokenId, founderId, newFounder); + + // Update the base token id + baseTokenId = (baseTokenId + schedule) % 100; + } + } + + // Store the founders' details + settings.totalOwnership = uint8(totalOwnership); + settings.numFounders = uint8(numFounders); + } + } + + /// @dev Finds the next available base token id for a founder + /// @param _tokenId The ERC-721 token id + function _getNextTokenId(uint256 _tokenId) internal view returns (uint256) { + unchecked { + while (tokenRecipient[_tokenId].wallet != address(0)) { + _tokenId = (++_tokenId) % 100; + } + + return _tokenId; + } + } + + /// /// + /// MINT /// + /// /// + + /// @notice Mints tokens to the auction house for bidding and handles founder vesting + function mint() external nonReentrant returns (uint256 tokenId) { + // Cache the auction address + address minter = settings.auction; + + // Ensure the caller is the auction + if (msg.sender != minter) { + revert ONLY_AUCTION(); + } + + // Cannot realistically overflow + unchecked { + do { + // Get the next token to mint + tokenId = auctionOffset + settings.mintCount++; + + // Lookup whether the token is for a founder, and mint accordingly if so + } while (_isForFounder(tokenId)); + } + + // Mint the next available token to the auction house for bidding + _mint(minter, tokenId); + } + + /// @dev Overrides _mint to include attribute generation + /// @param _to The token recipient + /// @param _tokenId The ERC-721 token id + function _mint(address _to, uint256 _tokenId) internal override { + // Mint the token + super._mint(_to, _tokenId); + + // Increment the total supply + unchecked { + ++settings.totalSupply; + } + + // Generate the token attributes + if (!settings.metadataRenderer.onMinted(_tokenId)) revert NO_METADATA_GENERATED(); + } + + /// @dev Checks if a given token is for a founder and mints accordingly + /// @param _tokenId The ERC-721 token id + function _isForFounder(uint256 _tokenId) private returns (bool) { + // Get the base token id + uint256 baseTokenId = _tokenId % 100; + + // If there is no scheduled recipient: + if (tokenRecipient[baseTokenId].wallet == address(0)) { + return false; + + // Else if the founder is still vesting: + } else if (block.timestamp < tokenRecipient[baseTokenId].vestExpiry) { + // Mint the token to the founder + _mint(tokenRecipient[baseTokenId].wallet, _tokenId); + + return true; + + // Else the founder has finished vesting: + } else { + // Remove them from future lookups + delete tokenRecipient[baseTokenId]; + + return false; + } + } + + /// /// + /// BURN /// + /// /// + + /// @notice Burns a token that did not see any bids + /// @param _tokenId The ERC-721 token id + function burn(uint256 _tokenId) external { + // Ensure the caller is the auction house + if (msg.sender != settings.auction) { + revert ONLY_AUCTION(); + } + + // Burn the token + _burn(_tokenId); + } + + function _burn(uint256 _tokenId) internal override { + super._burn(_tokenId); + + unchecked { + --settings.totalSupply; + } + } + + /// /// + /// METADATA /// + /// /// + + /// @notice The URI for a token + /// @param _tokenId The ERC-721 token id + function tokenURI(uint256 _tokenId) public view override(IToken, ERC721) returns (string memory) { + return settings.metadataRenderer.tokenURI(_tokenId); + } + + /// @notice The URI for the contract + function contractURI() public view override(IToken, ERC721) returns (string memory) { + return settings.metadataRenderer.contractURI(); + } + + /// /// + /// FOUNDERS /// + /// /// + + /// @notice The number of founders + function totalFounders() external view returns (uint256) { + return settings.numFounders; + } + + /// @notice The founders total percent ownership + function totalFounderOwnership() external view returns (uint256) { + return settings.totalOwnership; + } + + /// @notice The vesting details of a founder + /// @param _founderId The founder id + function getFounder(uint256 _founderId) external view returns (Founder memory) { + return founder[_founderId]; + } + + /// @notice The vesting details of all founders + function getFounders() external view returns (Founder[] memory) { + // Cache the number of founders + uint256 numFounders = settings.numFounders; + + // Get a temporary array to hold all founders + Founder[] memory founders = new Founder[](numFounders); + + // Cannot realistically overflow + unchecked { + // Add each founder to the array + for (uint256 i; i < numFounders; ++i) { + founders[i] = founder[i]; + } + } + + return founders; + } + + /// @notice The founder scheduled to receive the given token id + /// NOTE: If a founder is returned, there's no guarantee they'll receive the token as vesting expiration is not considered + /// @param _tokenId The ERC-721 token id + function getScheduledRecipient(uint256 _tokenId) external view returns (Founder memory) { + return tokenRecipient[_tokenId % 100]; + } + + /// @notice Update the list of allocation owners + /// @param newFounders the full list of founders + function updateFounders(IManager.FounderParams[] calldata newFounders) external onlyOwner { + // Cache the number of founders + uint256 numFounders = settings.numFounders; + + // Get a temporary array to hold all founders + Founder[] memory cachedFounders = new Founder[](numFounders); + + // Cannot realistically overflow + unchecked { + // Add each founder to the array + for (uint256 i; i < numFounders; ++i) { + cachedFounders[i] = founder[i]; + } + } + + // Keep a mapping of all the reserved token IDs we're set to clear. + bool[] memory clearedTokenIds = new bool[](100); + + unchecked { + // for each existing founder: + for (uint256 i; i < cachedFounders.length; ++i) { + // copy the founder into memory + Founder memory cachedFounder = cachedFounders[i]; + + // using the ownership percentage, get reserved token percentages + uint256 schedule = 100 / cachedFounder.ownershipPct; + + // Used to reverse engineer the indices the founder has reserved tokens in. + uint256 baseTokenId; + + for (uint256 j; j < cachedFounder.ownershipPct; ++j) { + // Get the next index that hasn't already been cleared + while (clearedTokenIds[baseTokenId] != false) { + baseTokenId = (++baseTokenId) % 100; + } + + delete tokenRecipient[baseTokenId]; + clearedTokenIds[baseTokenId] = true; + + emit MintUnscheduled(baseTokenId, i, cachedFounder); + + // Update the base token id + baseTokenId = (baseTokenId + schedule) % 100; + } + + // Delete the founder from the stored mapping + delete founder[i]; + } + } + + settings.numFounders = 0; + settings.totalOwnership = 0; + emit FounderAllocationsCleared(newFounders); + + _addFounders(newFounders); + } + + /// /// + /// SETTINGS /// + /// /// + + /// @notice The total supply of tokens + function totalSupply() external view returns (uint256) { + return settings.totalSupply; + } + + /// @notice The address of the auction house + function auction() external view returns (address) { + return settings.auction; + } + + /// @notice The address of the metadata renderer + function metadataRenderer() external view returns (address) { + return address(settings.metadataRenderer); + } + + function owner() public view override(IToken, Ownable) returns (address) { + return super.owner(); + } + + /// /// + /// EXISTING COMMUNITY /// + /// /// + + /// @dev Sets a new merkle root + /// @param _merkleRoot The new merkle root + function setMerkleRoot(bytes32 _merkleRoot) external onlyOwner { + merkleRoot = _merkleRoot; + } + + /// @dev Sets a new auction offset + /// @param _auctionOffset The new auction offset + function setAuctionOffset(uint256 _auctionOffset) external onlyOwner { + auctionOffset = _auctionOffset; + } + + /// @dev Sets a new is claim open value + /// @param _isClaimOpen The new is claim open + function setIsClaimOpen(bool _isClaimOpen) external onlyOwner { + isClaimOpen = _isClaimOpen; + } + + /// @dev Claims a token for a given address if proof is valid + /// @param _to The receiver address + /// @param _tokenId The token id + /// @param _proof The merkle proof + function claim(address _to, uint256 _tokenId, bytes32[] calldata _proof) external payable { + require(isClaimOpen, "Claim is not open"); + require(!claimed[_tokenId], "Token already claimed"); + bytes32 leaf = keccak256(abi.encodePacked(_to, _tokenId)); + require(MerkleProof.verify(_proof, merkleRoot, leaf), "Invalid proof"); + claimed[_tokenId] = true; + _mint(_to, _tokenId); + } + + /// @dev Claims multiple tokens for their given addresses if proofs are valid + /// @param _to an array of receiver addresses + /// @param _tokenId an array of token ids + /// @param _proof an array of merkle proofs + function claim(address[] calldata _to, uint256[] calldata _tokenId, bytes32[][] calldata _proof) external payable { + require(isClaimOpen, "Claim is not open"); + require( + _to.length <= 30 && + _to.length == _tokenId.length && + _to.length == _proof.length + , "Invalid input"); + for (uint256 i = 0; i < _to.length; i++) { + require(!claimed[_tokenId[i]], "Token already claimed"); + bytes32 leaf = keccak256(abi.encodePacked(_to[i], _tokenId[i])); + require(MerkleProof.verify(_proof[i], merkleRoot, leaf), "Invalid proof"); + claimed[_tokenId[i]] = true; + _mint(_to[i], _tokenId[i]); + } + } + + /// @dev Sends all ETH to the treasury + function rescueEth() external { + (, , address treasury, ) = manager.getAddresses(address(this)); + (bool success, ) = treasury.call{value: address(this).balance}(""); + require(success); + } + + /// /// + /// TOKEN UPGRADE /// + /// /// + + /// @notice Ensures the caller is authorized to upgrade the contract and that the new implementation is valid + /// @dev This function is called in `upgradeTo` & `upgradeToAndCall` + /// @param _newImpl The new implementation address + function _authorizeUpgrade(address _newImpl) internal view override { + // Ensure the caller is the shared owner of the token and metadata renderer + if (msg.sender != owner()) revert ONLY_OWNER(); + + // Ensure the implementation is valid + if (!manager.isRegisteredUpgrade(_getImplementation(), _newImpl)) revert INVALID_UPGRADE(_newImpl); + } +} diff --git a/src/token/storage/ExistingCommunityStorage.sol b/src/token/storage/ExistingCommunityStorage.sol new file mode 100644 index 0000000..c5c05fc --- /dev/null +++ b/src/token/storage/ExistingCommunityStorage.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.16; + +import { TokenTypesV1 } from "../types/TokenTypesV1.sol"; + +/// @title TokenStorageV1 +/// @author Rohan Kulkarni +/// @notice The Token storage contract +contract ExistingCommunityStorage is TokenTypesV1 { + /// @notice The token settings + Settings internal settings; + + /// @notice The vesting details of a founder + /// @dev Founder id => Founder + mapping(uint256 => Founder) internal founder; + + /// @notice The recipient of a token + /// @dev ERC-721 token id => Founder + mapping(uint256 => Founder) internal tokenRecipient; + + mapping(uint256 => bool) internal claimed; + + bytes32 public merkleRoot; + + uint256 public auctionOffset; + + bool public isClaimOpen; +} diff --git a/test/ExistingCommunityToken.t.sol b/test/ExistingCommunityToken.t.sol new file mode 100644 index 0000000..8515e60 --- /dev/null +++ b/test/ExistingCommunityToken.t.sol @@ -0,0 +1,685 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.16; + +import { NounsBuilderTest } from "./utils/NounsBuilderTest.sol"; + +import { IManager, Manager } from "../src/manager/Manager.sol"; +import { IToken, Token } from "../src/token/Token.sol"; +import { ExistingCommunityToken } from "../src/token/ExistingCommunityToken.sol"; +import { TokenTypesV1 } from "../src/token/types/TokenTypesV1.sol"; + +contract ExistingCommunityTokenTest is NounsBuilderTest, TokenTypesV1 { + mapping(address => uint256) public mintedTokens; + + bytes32 public constant MOCK_MERKLE_ROOT = bytes32(0xa2fd410cfdf4feae6af4e7433c6d6eca93f76aac8e6f01116b356a079bae3c35); + bytes32 public constant MOCK_PROOF_1 = bytes32(0x0f9c98f672c0f1cfd202e53839af8b141f4091018d87b7f3559fc60e45a09275); + bytes32 public constant MOCK_PROOF_2 = bytes32(0xfe5e4692f2a38f9f3f46d1f3a8545c34024ac29bee2f6bea91a52b8643c0804b); + address public constant AIRDROP_RECIPIENT_1 = address(0xDe30040413b26d7Aa2B6Fc4761D80eb35Dcf97aD); + address public constant AIRDROP_RECIPIENT_2 = address(0xb1120f07C94d7F2E16C7F50707A26A74bF0B12Ec); + uint256 public constant AIRDROP_TOKEN_ID_1 = 5; + uint256 public constant AIRDROP_TOKEN_ID_2 = 10; + + + function setUp() public virtual override { + super.setUp(); + vm.startPrank(vm.addr(0xB0B)); + manager.registerUpgrade(tokenImpl, existingCommunityTokenImpl); + vm.stopPrank(); + } + + function test_DonateOnClaimAndWithdraw() public { + deployMock(); + tokenUpgrade(); + + ExistingCommunityToken newToken = ExistingCommunityToken(address(token)); + + vm.startPrank(token.owner()); + newToken.setMerkleRoot(MOCK_MERKLE_ROOT); + newToken.setIsClaimOpen(true); + vm.stopPrank(); + + bytes32[][] memory proofs = new bytes32[][](2); + proofs[0] = new bytes32[](1); + proofs[0][0] = MOCK_PROOF_1; + proofs[1] = new bytes32[](1); + proofs[1][0] = MOCK_PROOF_2; + + address[] memory tos = new address[](2); + tos[0] = AIRDROP_RECIPIENT_1; + tos[1] = AIRDROP_RECIPIENT_2; + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = AIRDROP_TOKEN_ID_1; + tokenIds[1] = AIRDROP_TOKEN_ID_2; + + vm.deal(AIRDROP_RECIPIENT_1, 1 ether); + vm.prank(AIRDROP_RECIPIENT_1); + newToken.claim{value: 1 ether}(tos, tokenIds, proofs); + newToken.rescueEth(); + + assertEq(address(token).balance, 0); + assertEq(address(treasury).balance, 1 ether); + assertEq(token.ownerOf(AIRDROP_TOKEN_ID_1), AIRDROP_RECIPIENT_1); + assertEq(token.ownerOf(AIRDROP_TOKEN_ID_2), AIRDROP_RECIPIENT_2); + assertEq(token.totalSupply(), 2); + } + + function test_UpgradedTokenImplMerkleTree() public { + deployMock(); + tokenUpgrade(); + + ExistingCommunityToken newToken = ExistingCommunityToken(address(token)); + + vm.startPrank(token.owner()); + newToken.setMerkleRoot(MOCK_MERKLE_ROOT); + newToken.setIsClaimOpen(true); + vm.stopPrank(); + + bytes32[][] memory proofs = new bytes32[][](2); + proofs[0] = new bytes32[](1); + proofs[0][0] = MOCK_PROOF_1; + proofs[1] = new bytes32[](1); + proofs[1][0] = MOCK_PROOF_2; + + address[] memory tos = new address[](2); + tos[0] = AIRDROP_RECIPIENT_1; + tos[1] = AIRDROP_RECIPIENT_2; + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = AIRDROP_TOKEN_ID_1; + tokenIds[1] = AIRDROP_TOKEN_ID_2; + + vm.prank(AIRDROP_RECIPIENT_1); + newToken.claim(tos, tokenIds, proofs); + + assertEq(token.ownerOf(AIRDROP_TOKEN_ID_1), AIRDROP_RECIPIENT_1); + assertEq(token.ownerOf(AIRDROP_TOKEN_ID_2), AIRDROP_RECIPIENT_2); + assertEq(token.totalSupply(), 2); + } + + + function test_UpgradedTokenImplAuction() public { + deployMock(); + tokenUpgrade(); + + ExistingCommunityToken newToken = ExistingCommunityToken(address(token)); + + address bidder1 = vm.addr(0xB1); + address bidder2 = vm.addr(0xB2); + uint256 NEW_AUCTION_OFFSET = 123; + + vm.deal(bidder1, 1000 ether); + vm.deal(bidder2, 1000 ether); + + vm.startPrank(token.owner()); + newToken.setAuctionOffset(NEW_AUCTION_OFFSET); + newToken.setMerkleRoot(MOCK_MERKLE_ROOT); + newToken.setIsClaimOpen(true); + vm.stopPrank(); + + vm.prank(founder); + auction.unpause(); + + vm.prank(bidder1); + auction.createBid{ value: 0.420 ether }(NEW_AUCTION_OFFSET); + + vm.prank(bidder2); + auction.createBid{ value: 1 ether }(NEW_AUCTION_OFFSET); + + vm.warp(10 minutes + 1 seconds); + + auction.settleCurrentAndCreateNewAuction(); + + bytes32[] memory proof = new bytes32[](1); + proof[0] = MOCK_PROOF_1; + vm.prank(AIRDROP_RECIPIENT_1); + newToken.claim(AIRDROP_RECIPIENT_1, AIRDROP_TOKEN_ID_1, proof); + + assertEq(token.ownerOf(NEW_AUCTION_OFFSET), bidder2); + assertEq(token.ownerOf(AIRDROP_TOKEN_ID_1), AIRDROP_RECIPIENT_1); + assertEq(token.getVotes(bidder2), 1); + + assertEq(address(treasury).balance, 1 ether); + + assertEq(token.totalSupply(), 3); + vm.stopPrank(); + } + + function test_MockTokenInit() public { + deployMock(); + tokenUpgrade(); + + assertEq(token.name(), "Mock Token"); + assertEq(token.symbol(), "MOCK"); + assertEq(token.auction(), address(auction)); + // Initial token owner until first auction is the founder. + assertEq(token.owner(), address(founder)); + assertEq(token.metadataRenderer(), address(metadataRenderer)); + assertEq(token.totalSupply(), 0); + } + + /// Test that the percentages for founders all ends up as expected + function test_FounderShareAllocationFuzz( + uint256 f1Percentage, + uint256 f2Percentage, + uint256 f3Percentage + ) public { + address f1Wallet = address(0x1); + address f2Wallet = address(0x2); + address f3Wallet = address(0x3); + + vm.assume(f1Percentage > 0 && f1Percentage < 100); + vm.assume(f2Percentage > 0 && f2Percentage < 100); + vm.assume(f3Percentage > 0 && f3Percentage < 100); + vm.assume(f1Percentage + f2Percentage + f3Percentage < 99); + + address[] memory founders = new address[](3); + uint256[] memory percents = new uint256[](3); + uint256[] memory vestingEnds = new uint256[](3); + + founders[0] = f1Wallet; + founders[1] = f2Wallet; + founders[2] = f3Wallet; + + percents[0] = f1Percentage; + percents[1] = f2Percentage; + percents[2] = f3Percentage; + + vestingEnds[0] = 4 weeks; + vestingEnds[1] = 4 weeks; + vestingEnds[2] = 4 weeks; + + deployWithCustomFounders(founders, percents, vestingEnds); + tokenUpgrade(); + + Founder memory f1 = token.getFounder(0); + Founder memory f2 = token.getFounder(1); + Founder memory f3 = token.getFounder(2); + + assertEq(f1.ownershipPct, f1Percentage); + assertEq(f2.ownershipPct, f2Percentage); + assertEq(f3.ownershipPct, f3Percentage); + + // Mint 100 tokens + for (uint256 i = 0; i < 100; i++) { + vm.prank(address(auction)); + token.mint(); + + mintedTokens[token.ownerOf(i)] += 1; + } + + // Read the ownership of only the first 100 minted tokens + // Note that the # of tokens minted above can exceed 100, therefore + // we do our own count because we cannot use balanceOf(). + + assertEq(mintedTokens[f1Wallet], f1Percentage); + assertEq(mintedTokens[f2Wallet], f2Percentage); + assertEq(mintedTokens[f3Wallet], f3Percentage); + } + + function test_MockFounders() public { + deployMock(); + tokenUpgrade(); + + assertEq(token.totalFounders(), 2); + assertEq(token.totalFounderOwnership(), 15); + + Founder[] memory fdrs = token.getFounders(); + + assertEq(fdrs.length, 2); + + Founder memory fdr1 = fdrs[0]; + Founder memory fdr2 = fdrs[1]; + + assertEq(fdr1.wallet, foundersArr[0].wallet); + assertEq(fdr1.ownershipPct, foundersArr[0].ownershipPct); + assertEq(fdr1.vestExpiry, foundersArr[0].vestExpiry); + + assertEq(fdr2.wallet, foundersArr[1].wallet); + assertEq(fdr2.ownershipPct, foundersArr[1].ownershipPct); + assertEq(fdr2.vestExpiry, foundersArr[1].vestExpiry); + } + + function test_MockAuctionUnpause() public { + deployMock(); + tokenUpgrade(); + + vm.prank(founder); + auction.unpause(); + + assertEq(token.totalSupply(), 3); + + assertEq(token.ownerOf(0), founder); + assertEq(token.ownerOf(1), founder2); + assertEq(token.ownerOf(2), address(auction)); + + assertEq(token.balanceOf(founder), 1); + assertEq(token.balanceOf(founder2), 1); + assertEq(token.balanceOf(address(auction)), 1); + + assertEq(token.getVotes(founder), 1); + assertEq(token.getVotes(founder2), 1); + assertEq(token.getVotes(address(auction)), 1); + } + + function test_MaxOwnership99Founders() public { + createUsers(100, 1 ether); + + address[] memory wallets = new address[](100); + uint256[] memory percents = new uint256[](100); + uint256[] memory vestExpirys = new uint256[](100); + + uint8 pct = 1; + uint256 end = 4 weeks; + + unchecked { + for (uint256 i; i < 99; ++i) { + wallets[i] = otherUsers[i]; + percents[i] = pct; + vestExpirys[i] = end; + } + } + + deployWithCustomFounders(wallets, percents, vestExpirys); + tokenUpgrade(); + + assertEq(token.totalFounders(), 100); + assertEq(token.totalFounderOwnership(), 99); + + Founder memory founder; + + for (uint256 i; i < 99; ++i) { + founder = token.getScheduledRecipient(i); + + assertEq(founder.wallet, otherUsers[i]); + } + } + + function test_MaxOwnership50Founders() public { + createUsers(50, 1 ether); + + address[] memory wallets = new address[](50); + uint256[] memory percents = new uint256[](50); + uint256[] memory vestExpirys = new uint256[](50); + + uint8 pct = 2; + uint256 end = 4 weeks; + + unchecked { + for (uint256 i; i < 50; ++i) { + wallets[i] = otherUsers[i]; + percents[i] = pct; + vestExpirys[i] = end; + } + } + percents[49] = 1; + + deployWithCustomFounders(wallets, percents, vestExpirys); + tokenUpgrade(); + + assertEq(token.totalFounders(), 50); + assertEq(token.totalFounderOwnership(), 99); + + Founder memory founder; + + for (uint256 i; i < 49; ++i) { + founder = token.getScheduledRecipient(i); + + assertEq(founder.wallet, otherUsers[i]); + + founder = token.getScheduledRecipient(i + 50); + + assertEq(founder.wallet, otherUsers[i]); + } + } + + function test_MaxOwnership2Founders() public { + createUsers(2, 1 ether); + + address[] memory wallets = new address[](2); + uint256[] memory percents = new uint256[](2); + uint256[] memory vestExpirys = new uint256[](2); + + uint8 pct = 49; + uint256 end = 4 weeks; + + unchecked { + for (uint256 i; i < 2; ++i) { + wallets[i] = otherUsers[i]; + vestExpirys[i] = end; + percents[i] = pct; + } + } + + deployWithCustomFounders(wallets, percents, vestExpirys); + tokenUpgrade(); + + assertEq(token.totalFounders(), 2); + assertEq(token.totalFounderOwnership(), 98); + + Founder memory founder; + + unchecked { + for (uint256 i; i < 500; ++i) { + founder = token.getScheduledRecipient(i); + + if (i % 100 >= 98) { + continue; + } + + if (i % 2 == 0) { + assertEq(founder.wallet, otherUsers[0]); + } else { + assertEq(founder.wallet, otherUsers[1]); + } + } + } + } + + // Test that when tokens are minted / burned over time, + // no two tokens end up with the same ID + function test_TokenIdCollisionAvoidance(uint8 mintCount) public { + deployMock(); + tokenUpgrade(); + + // avoid overflows specific to this test, shouldn't occur in practice + vm.assume(mintCount < 100); + + uint256 lastTokenId = type(uint256).max; + + for (uint8 i = 0; i <= mintCount; i++) { + vm.prank(address(auction)); + uint256 tokenId = token.mint(); + + assertFalse(tokenId == lastTokenId); + lastTokenId = tokenId; + + vm.prank(address(auction)); + token.burn(tokenId); + } + } + + function test_FounderScheduleRounding() public { + createUsers(3, 1 ether); + + address[] memory wallets = new address[](3); + uint256[] memory percents = new uint256[](3); + uint256[] memory vestExpirys = new uint256[](3); + + percents[0] = 11; + percents[1] = 12; + percents[2] = 13; + + unchecked { + for (uint256 i; i < 3; ++i) { + wallets[i] = otherUsers[i]; + vestExpirys[i] = 4 weeks; + } + } + + deployWithCustomFounders(wallets, percents, vestExpirys); + tokenUpgrade(); + } + + function test_FounderScheduleRounding2() public { + createUsers(11, 1 ether); + + address[] memory wallets = new address[](11); + uint256[] memory percents = new uint256[](11); + uint256[] memory vestExpirys = new uint256[](11); + + percents[0] = 1; + percents[1] = 1; + percents[2] = 1; + percents[3] = 1; + percents[4] = 1; + + percents[5] = 10; + percents[6] = 10; + percents[7] = 10; + percents[8] = 10; + percents[9] = 10; + + percents[10] = 20; + + unchecked { + for (uint256 i; i < 11; ++i) { + wallets[i] = otherUsers[i]; + vestExpirys[i] = 4 weeks; + } + } + + deployWithCustomFounders(wallets, percents, vestExpirys); + tokenUpgrade(); + } + + function test_OverwriteCheckpointWithSameTimestamp() public { + deployMock(); + tokenUpgrade(); + + vm.prank(founder); + auction.unpause(); + + assertEq(token.balanceOf(founder), 1); + assertEq(token.getVotes(founder), 1); + assertEq(token.delegates(founder), founder); + + (uint256 nextTokenId, , , , , ) = auction.auction(); + + vm.deal(founder, 1 ether); + + vm.prank(founder); + auction.createBid{ value: 0.5 ether }(nextTokenId); // Checkpoint #0, Timestamp 1 sec + + vm.warp(block.timestamp + 10 minutes); // Checkpoint #1, Timestamp 10 min + 1 sec + + auction.settleCurrentAndCreateNewAuction(); + + assertEq(token.balanceOf(founder), 2); + assertEq(token.getVotes(founder), 2); + assertEq(token.delegates(founder), founder); + + vm.prank(founder); + token.delegate(address(this)); // Checkpoint #1 overwrite + + assertEq(token.getVotes(founder), 0); + assertEq(token.delegates(founder), address(this)); + assertEq(token.balanceOf(address(this)), 0); + assertEq(token.getVotes(address(this)), 2); + + vm.prank(founder); + token.delegate(founder); // Checkpoint #1 overwrite + + assertEq(token.getVotes(founder), 2); + assertEq(token.delegates(founder), founder); + assertEq(token.getVotes(address(this)), 0); + + vm.warp(block.timestamp + 1); // Checkpoint #2, Timestamp 10 min + 2 sec + + vm.prank(founder); + token.transferFrom(founder, address(this), 0); + + assertEq(token.getVotes(founder), 1); + + // Ensure the votes returned from the binary search is the latest overwrite of checkpoint 1 + assertEq(token.getPastVotes(founder, block.timestamp - 1), 2); + } + + function testRevert_OnlyAuctionCanMint() public { + deployMock(); + tokenUpgrade(); + + vm.prank(founder); + auction.unpause(); + + vm.expectRevert(abi.encodeWithSignature("ONLY_AUCTION()")); + token.mint(); + } + + function testRevert_OnlyAuctionCanBurn() public { + deployMock(); + tokenUpgrade(); + + vm.prank(founder); + auction.unpause(); + + vm.expectRevert(abi.encodeWithSignature("ONLY_AUCTION()")); + token.burn(1); + } + + function testRevert_OnlyDAOCanUpgrade() public { + deployMock(); + tokenUpgrade(); + + vm.prank(founder); + auction.unpause(); + + vm.expectRevert(abi.encodeWithSignature("ONLY_OWNER()")); + token.upgradeTo(address(this)); + } + + function testRevert_OnlyDAOCanUpgradeToAndCall() public { + deployMock(); + tokenUpgrade(); + + vm.prank(founder); + auction.unpause(); + + vm.expectRevert(abi.encodeWithSignature("ONLY_OWNER()")); + token.upgradeToAndCall(address(this), ""); + } + + function testFoundersCannotHaveFullOwnership() public { + createUsers(2, 1 ether); + + address[] memory wallets = new address[](2); + uint256[] memory percents = new uint256[](2); + uint256[] memory vestExpirys = new uint256[](2); + + uint256 end = 4 weeks; + wallets[0] = otherUsers[0]; + vestExpirys[0] = end; + wallets[1] = otherUsers[1]; + vestExpirys[1] = end; + percents[0] = 50; + percents[1] = 49; + + deployWithCustomFounders(wallets, percents, vestExpirys); + tokenUpgrade(); + + assertEq(token.totalFounders(), 2); + assertEq(token.totalFounderOwnership(), 99); + + Founder memory founder; + + unchecked { + for (uint256 i; i < 99; ++i) { + founder = token.getScheduledRecipient(i); + + if (i % 2 == 0) { + assertEq(founder.wallet, otherUsers[0]); + } else { + assertEq(founder.wallet, otherUsers[1]); + } + } + } + + vm.prank(otherUsers[0]); + auction.unpause(); + } + + function testRevert_OnlyOwnerUpdateFounders() public { + deployMock(); + tokenUpgrade(); + + address f1Wallet = address(0x1); + address f2Wallet = address(0x2); + address f3Wallet = address(0x3); + + address[] memory founders = new address[](3); + uint256[] memory percents = new uint256[](3); + uint256[] memory vestingEnds = new uint256[](3); + + founders[0] = f1Wallet; + founders[1] = f2Wallet; + founders[2] = f3Wallet; + + percents[0] = 1; + percents[1] = 2; + percents[2] = 3; + + vestingEnds[0] = 4 weeks; + vestingEnds[1] = 4 weeks; + vestingEnds[2] = 4 weeks; + + setFounderParams(founders, percents, vestingEnds); + + vm.prank(f1Wallet); + vm.expectRevert(abi.encodeWithSignature("ONLY_OWNER()")); + + token.updateFounders(foundersArr); + } + + function test_UpdateFounderShareAllocationFuzz( + uint256 f1Percentage, + uint256 f2Percentage, + uint256 f3Percentage + ) public { + deployMock(); + tokenUpgrade(); + + address f1Wallet = address(0x1); + address f2Wallet = address(0x2); + address f3Wallet = address(0x3); + + vm.assume(f1Percentage > 0 && f1Percentage < 100); + vm.assume(f2Percentage > 0 && f2Percentage < 100); + vm.assume(f3Percentage > 0 && f3Percentage < 100); + vm.assume(f1Percentage + f2Percentage + f3Percentage < 99); + + address[] memory founders = new address[](3); + uint256[] memory percents = new uint256[](3); + uint256[] memory vestingEnds = new uint256[](3); + + founders[0] = f1Wallet; + founders[1] = f2Wallet; + founders[2] = f3Wallet; + + percents[0] = f1Percentage; + percents[1] = f2Percentage; + percents[2] = f3Percentage; + + vestingEnds[0] = 4 weeks; + vestingEnds[1] = 4 weeks; + vestingEnds[2] = 4 weeks; + + setFounderParams(founders, percents, vestingEnds); + + vm.prank(address(founder)); + token.updateFounders(foundersArr); + + Founder memory f1 = token.getFounder(0); + Founder memory f2 = token.getFounder(1); + Founder memory f3 = token.getFounder(2); + + assertEq(f1.ownershipPct, f1Percentage); + assertEq(f2.ownershipPct, f2Percentage); + assertEq(f3.ownershipPct, f3Percentage); + + // Mint 100 tokens + for (uint256 i = 0; i < 100; i++) { + vm.prank(address(auction)); + token.mint(); + + mintedTokens[token.ownerOf(i)] += 1; + } + + // Read the ownership of only the first 100 minted tokens + // Note that the # of tokens minted above can exceed 100, therefore + // we do our own count because we cannot use balanceOf(). + + assertEq(mintedTokens[f1Wallet], f1Percentage); + assertEq(mintedTokens[f2Wallet], f2Percentage); + assertEq(mintedTokens[f3Wallet], f3Percentage); + } +} diff --git a/test/utils/NounsBuilderTest.sol b/test/utils/NounsBuilderTest.sol index bb886f0..ad1794f 100644 --- a/test/utils/NounsBuilderTest.sol +++ b/test/utils/NounsBuilderTest.sol @@ -5,6 +5,7 @@ import { Test } from "forge-std/Test.sol"; import { IManager, Manager } from "../../src/manager/Manager.sol"; import { IToken, Token } from "../../src/token/Token.sol"; +import { ExistingCommunityToken } from "../../src/token/ExistingCommunityToken.sol"; import { MetadataRenderer } from "../../src/token/metadata/MetadataRenderer.sol"; import { IAuction, Auction } from "../../src/auction/Auction.sol"; import { IGovernor, Governor } from "../../src/governance/governor/Governor.sol"; @@ -27,6 +28,7 @@ contract NounsBuilderTest is Test { address internal managerImpl0; address internal managerImpl; address internal tokenImpl; + address internal existingCommunityTokenImpl; address internal metadataRendererImpl; address internal auctionImpl; address internal treasuryImpl; @@ -63,6 +65,7 @@ contract NounsBuilderTest is Test { manager = Manager(address(new ERC1967Proxy(managerImpl0, abi.encodeWithSignature("initialize(address)", zoraDAO)))); tokenImpl = address(new Token(address(manager))); + existingCommunityTokenImpl = address(new ExistingCommunityToken(address(manager))); metadataRendererImpl = address(new MetadataRenderer(address(manager))); auctionImpl = address(new Auction(address(manager), weth)); treasuryImpl = address(new Treasury(address(manager))); @@ -210,6 +213,11 @@ contract NounsBuilderTest is Test { setMockMetadata(); } + function tokenUpgrade() internal virtual { + vm.prank(token.owner()); + token.upgradeTo(existingCommunityTokenImpl); + } + function deployWithCustomFounders( address[] memory _wallets, uint256[] memory _percents,