From 2c66f218517cade3ea48d2b6e21072883efc97d3 Mon Sep 17 00:00:00 2001 From: neokry Date: Tue, 8 Aug 2023 15:35:32 +0900 Subject: [PATCH 1/4] Add partial soulbound token --- script/DeployContracts.s.sol | 2 +- script/DeployMetadataUpgrade.s.sol | 2 +- script/DeployTokenUpgrade.s.sol | 2 +- script/DeployVersion1_1.s.sol | 2 +- src/auction/Auction.sol | 4 +- src/auction/storage/AuctionStorageV1.sol | 4 +- src/governance/governor/Governor.sol | 7 +- .../governor/types/GovernorTypesV1.sol | 4 +- src/lib/interfaces/IERC5192.sol | 20 + src/manager/Manager.sol | 2 +- src/token/{ => default}/IToken.sol | 9 +- src/token/{ => default}/Token.sol | 19 +- .../{ => default}/storage/TokenStorageV1.sol | 0 .../{ => default}/storage/TokenStorageV2.sol | 0 .../{ => default}/types/TokenTypesV1.sol | 2 +- .../{ => default}/types/TokenTypesV2.sol | 0 src/token/interfaces/IBaseToken.sol | 54 ++ src/token/metadata/MetadataRenderer.sol | 2 +- .../IPartialSoulboundToken.sol | 151 ++++++ .../PartialSoulboundToken.sol | 479 ++++++++++++++++++ .../PartialSoulboundTokenStorageV1.sol | 23 + .../types/PartialSoulboundTokenTypesV1.sol | 40 ++ test/Token.t.sol | 6 +- test/forking/TestBid.t.sol | 2 +- test/forking/TestUpdateMinters.t.sol | 4 +- test/forking/TestUpdateOwners.t.sol | 24 +- test/utils/NounsBuilderTest.sol | 2 +- 27 files changed, 817 insertions(+), 49 deletions(-) create mode 100644 src/lib/interfaces/IERC5192.sol rename src/token/{ => default}/IToken.sol (95%) rename src/token/{ => default}/Token.sol (96%) rename src/token/{ => default}/storage/TokenStorageV1.sol (100%) rename src/token/{ => default}/storage/TokenStorageV2.sol (100%) rename src/token/{ => default}/types/TokenTypesV1.sol (93%) rename src/token/{ => default}/types/TokenTypesV2.sol (100%) create mode 100644 src/token/interfaces/IBaseToken.sol create mode 100644 src/token/partial-soulbound/IPartialSoulboundToken.sol create mode 100644 src/token/partial-soulbound/PartialSoulboundToken.sol create mode 100644 src/token/partial-soulbound/storage/PartialSoulboundTokenStorageV1.sol create mode 100644 src/token/partial-soulbound/types/PartialSoulboundTokenTypesV1.sol diff --git a/script/DeployContracts.s.sol b/script/DeployContracts.s.sol index d386443..6cb9ce6 100644 --- a/script/DeployContracts.s.sol +++ b/script/DeployContracts.s.sol @@ -5,7 +5,7 @@ import "forge-std/Script.sol"; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { IManager, Manager } from "../src/manager/Manager.sol"; -import { IToken, Token } from "../src/token/Token.sol"; +import { IToken, Token } from "../src/token/default/Token.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"; diff --git a/script/DeployMetadataUpgrade.s.sol b/script/DeployMetadataUpgrade.s.sol index 73192b4..1b6875d 100644 --- a/script/DeployMetadataUpgrade.s.sol +++ b/script/DeployMetadataUpgrade.s.sol @@ -5,7 +5,7 @@ import "forge-std/Script.sol"; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { IManager, Manager } from "../src/manager/Manager.sol"; -import { IToken, Token } from "../src/token/Token.sol"; +import { IToken, Token } from "../src/token/default/Token.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"; diff --git a/script/DeployTokenUpgrade.s.sol b/script/DeployTokenUpgrade.s.sol index 58753fa..245abfa 100644 --- a/script/DeployTokenUpgrade.s.sol +++ b/script/DeployTokenUpgrade.s.sol @@ -6,7 +6,7 @@ import "forge-std/console2.sol"; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { IManager, Manager } from "../src/manager/Manager.sol"; -import { IToken, Token } from "../src/token/Token.sol"; +import { IToken, Token } from "../src/token/default/Token.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"; diff --git a/script/DeployVersion1_1.s.sol b/script/DeployVersion1_1.s.sol index 6620dba..b92bf96 100644 --- a/script/DeployVersion1_1.s.sol +++ b/script/DeployVersion1_1.s.sol @@ -6,7 +6,7 @@ import "forge-std/console2.sol"; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { IManager, Manager } from "../src/manager/Manager.sol"; -import { IToken, Token } from "../src/token/Token.sol"; +import { IToken, Token } from "../src/token/default/Token.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"; diff --git a/src/auction/Auction.sol b/src/auction/Auction.sol index 55527e1..70ff83c 100644 --- a/src/auction/Auction.sol +++ b/src/auction/Auction.sol @@ -8,7 +8,7 @@ import { Pausable } from "../lib/utils/Pausable.sol"; import { SafeCast } from "../lib/utils/SafeCast.sol"; import { AuctionStorageV1 } from "./storage/AuctionStorageV1.sol"; -import { Token } from "../token/Token.sol"; +import { IBaseToken } from "../token/interfaces/IBaseToken.sol"; import { IManager } from "../manager/IManager.sol"; import { IAuction } from "./IAuction.sol"; import { IWETH } from "../lib/interfaces/IWETH.sol"; @@ -78,7 +78,7 @@ contract Auction is IAuction, VersionedContract, UUPS, Ownable, ReentrancyGuard, __Pausable_init(true); // Store DAO's ERC-721 token - token = Token(_token); + token = IBaseToken(_token); AuctionParams memory params = abi.decode(_data, (AuctionParams)); diff --git a/src/auction/storage/AuctionStorageV1.sol b/src/auction/storage/AuctionStorageV1.sol index fc06e43..046a07d 100644 --- a/src/auction/storage/AuctionStorageV1.sol +++ b/src/auction/storage/AuctionStorageV1.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.16; -import { Token } from "../../token/Token.sol"; +import { IBaseToken } from "../../token/interfaces/IBaseToken.sol"; import { AuctionTypesV1 } from "../types/AuctionTypesV1.sol"; /// @title AuctionStorageV1 @@ -12,7 +12,7 @@ contract AuctionStorageV1 is AuctionTypesV1 { Settings internal settings; /// @notice The ERC-721 token - Token public token; + IBaseToken public token; /// @notice The state of the current auction Auction public auction; diff --git a/src/governance/governor/Governor.sol b/src/governance/governor/Governor.sol index 87516f4..8514cda 100644 --- a/src/governance/governor/Governor.sol +++ b/src/governance/governor/Governor.sol @@ -5,9 +5,10 @@ import { UUPS } from "../../lib/proxy/UUPS.sol"; import { Ownable } from "../../lib/utils/Ownable.sol"; import { EIP712 } from "../../lib/utils/EIP712.sol"; import { SafeCast } from "../../lib/utils/SafeCast.sol"; +import { ERC721 } from "../../lib/token/ERC721.sol"; import { GovernorStorageV1 } from "./storage/GovernorStorageV1.sol"; -import { Token } from "../../token/Token.sol"; +import { IBaseToken } from "../../token/interfaces/IBaseToken.sol"; import { Treasury } from "../treasury/Treasury.sol"; import { IManager } from "../../manager/IManager.sol"; import { IGovernor } from "./IGovernor.sol"; @@ -112,14 +113,14 @@ contract Governor is IGovernor, VersionedContract, UUPS, Ownable, EIP712, Propos // Store the governor settings settings.treasury = Treasury(payable(_treasury)); - settings.token = Token(_token); + settings.token = IBaseToken(_token); settings.votingDelay = SafeCast.toUint48(params.votingDelay); settings.votingPeriod = SafeCast.toUint48(params.votingPeriod); settings.proposalThresholdBps = SafeCast.toUint16(params.proposalThresholdBps); settings.quorumThresholdBps = SafeCast.toUint16(params.quorumThresholdBps); // Initialize EIP-712 support - __EIP712_init(string.concat(settings.token.symbol(), " GOV"), "1"); + __EIP712_init(string.concat(ERC721(_token).symbol(), " GOV"), "1"); // Grant ownership to the treasury __Ownable_init(_treasury); diff --git a/src/governance/governor/types/GovernorTypesV1.sol b/src/governance/governor/types/GovernorTypesV1.sol index 0a411ba..1224ec3 100644 --- a/src/governance/governor/types/GovernorTypesV1.sol +++ b/src/governance/governor/types/GovernorTypesV1.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.16; -import { Token } from "../../../token/Token.sol"; +import { IBaseToken } from "../../../token/interfaces/IBaseToken.sol"; import { Treasury } from "../../treasury/Treasury.sol"; /// @title GovernorTypesV1 @@ -17,7 +17,7 @@ interface GovernorTypesV1 { /// @param votingPeriod The time period to vote on a proposal /// @param vetoer The address with the ability to veto proposals struct Settings { - Token token; + IBaseToken token; uint16 proposalThresholdBps; uint16 quorumThresholdBps; Treasury treasury; diff --git a/src/lib/interfaces/IERC5192.sol b/src/lib/interfaces/IERC5192.sol new file mode 100644 index 0000000..bb25f04 --- /dev/null +++ b/src/lib/interfaces/IERC5192.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.0; + +interface IERC5192 { + /// @notice Emitted when the locking status is changed to locked. + /// @dev If a token is minted and the status is locked, this event should be emitted. + /// @param tokenId The identifier for a token. + event Locked(uint256 tokenId); + + /// @notice Emitted when the locking status is changed to unlocked. + /// @dev If a token is minted and the status is unlocked, this event should be emitted. + /// @param tokenId The identifier for a token. + event Unlocked(uint256 tokenId); + + /// @notice Returns the locking status of an Soulbound Token + /// @dev SBTs assigned to zero address are considered invalid, and queries + /// about them do throw. + /// @param tokenId The identifier for an SBT. + function locked(uint256 tokenId) external view returns (bool); +} diff --git a/src/manager/Manager.sol b/src/manager/Manager.sol index 04dcf13..302769f 100644 --- a/src/manager/Manager.sol +++ b/src/manager/Manager.sol @@ -8,7 +8,7 @@ import { ERC1967Proxy } from "../lib/proxy/ERC1967Proxy.sol"; import { ManagerStorageV1 } from "./storage/ManagerStorageV1.sol"; import { ManagerStorageV2 } from "./storage/ManagerStorageV2.sol"; import { IManager } from "./IManager.sol"; -import { IToken } from "../token/IToken.sol"; +import { IToken } from "../token/default/IToken.sol"; import { IBaseMetadata } from "../token/metadata/interfaces/IBaseMetadata.sol"; import { IAuction } from "../auction/IAuction.sol"; import { ITreasury } from "../governance/treasury/ITreasury.sol"; diff --git a/src/token/IToken.sol b/src/token/default/IToken.sol similarity index 95% rename from src/token/IToken.sol rename to src/token/default/IToken.sol index de5465e..ea0eadb 100644 --- a/src/token/IToken.sol +++ b/src/token/default/IToken.sol @@ -1,16 +1,17 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.16; -import { IUUPS } from "../lib/interfaces/IUUPS.sol"; -import { IERC721Votes } from "../lib/interfaces/IERC721Votes.sol"; -import { IManager } from "../manager/IManager.sol"; +import { IUUPS } from "../../lib/interfaces/IUUPS.sol"; +import { IERC721Votes } from "../../lib/interfaces/IERC721Votes.sol"; +import { IManager } from "../../manager/IManager.sol"; +import { IBaseToken } from "../interfaces/IBaseToken.sol"; import { TokenTypesV1 } from "./types/TokenTypesV1.sol"; import { TokenTypesV2 } from "./types/TokenTypesV2.sol"; /// @title IToken /// @author Rohan Kulkarni /// @notice The external Token events, errors and functions -interface IToken is IUUPS, IERC721Votes, TokenTypesV1, TokenTypesV2 { +interface IToken is IUUPS, IERC721Votes, IBaseToken, TokenTypesV1, TokenTypesV2 { /// /// /// EVENTS /// /// /// diff --git a/src/token/Token.sol b/src/token/default/Token.sol similarity index 96% rename from src/token/Token.sol rename to src/token/default/Token.sol index 3da5c98..2f8c1a0 100644 --- a/src/token/Token.sol +++ b/src/token/default/Token.sol @@ -1,18 +1,19 @@ // SPDX-License-Identifier: MIT 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 { 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 { TokenStorageV1 } from "./storage/TokenStorageV1.sol"; import { TokenStorageV2 } from "./storage/TokenStorageV2.sol"; -import { IBaseMetadata } from "./metadata/interfaces/IBaseMetadata.sol"; -import { IManager } from "../manager/IManager.sol"; -import { IAuction } from "../auction/IAuction.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 { VersionedContract } from "../VersionedContract.sol"; +import { IBaseToken } from "../interfaces/IBaseToken.sol"; +import { VersionedContract } from "../../VersionedContract.sol"; /// @title Token /// @author Rohan Kulkarni diff --git a/src/token/storage/TokenStorageV1.sol b/src/token/default/storage/TokenStorageV1.sol similarity index 100% rename from src/token/storage/TokenStorageV1.sol rename to src/token/default/storage/TokenStorageV1.sol diff --git a/src/token/storage/TokenStorageV2.sol b/src/token/default/storage/TokenStorageV2.sol similarity index 100% rename from src/token/storage/TokenStorageV2.sol rename to src/token/default/storage/TokenStorageV2.sol diff --git a/src/token/types/TokenTypesV1.sol b/src/token/default/types/TokenTypesV1.sol similarity index 93% rename from src/token/types/TokenTypesV1.sol rename to src/token/default/types/TokenTypesV1.sol index e6fb4be..72162ea 100644 --- a/src/token/types/TokenTypesV1.sol +++ b/src/token/default/types/TokenTypesV1.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.16; -import { IBaseMetadata } from "../metadata/interfaces/IBaseMetadata.sol"; +import { IBaseMetadata } from "../../metadata/interfaces/IBaseMetadata.sol"; /// @title TokenTypesV1 /// @author Rohan Kulkarni diff --git a/src/token/types/TokenTypesV2.sol b/src/token/default/types/TokenTypesV2.sol similarity index 100% rename from src/token/types/TokenTypesV2.sol rename to src/token/default/types/TokenTypesV2.sol diff --git a/src/token/interfaces/IBaseToken.sol b/src/token/interfaces/IBaseToken.sol new file mode 100644 index 0000000..4dd4887 --- /dev/null +++ b/src/token/interfaces/IBaseToken.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.16; + +import { IERC721 } from "../../lib/interfaces/IERC721.sol"; +import { IERC721Votes } from "../../lib/interfaces/IERC721Votes.sol"; +import { IManager } from "../../manager/IManager.sol"; + +/// @title ITokenBase2 +/// @author Neokry +/// @notice The external Token events, errors and functions +interface IBaseToken is IERC721, IERC721Votes { + /// /// + /// FUNCTIONS /// + /// /// + + /// @notice Mints tokens to the caller and handles founder vesting + function mint() external returns (uint256 tokenId); + + /// @notice Mints tokens to the recipient and handles founder vesting + function mintTo(address recipient) external returns (uint256 tokenId); + + /// @notice Mints the specified amount of tokens to the recipient and handles founder vesting + function mintBatchTo(uint256 amount, address recipient) external returns (uint256[] memory tokenIds); + + /// @notice Burns a token owned by the caller + /// @param tokenId The ERC-721 token id + function burn(uint256 tokenId) external; + + /// @notice The URI for a token + /// @param tokenId The ERC-721 token id + function tokenURI(uint256 tokenId) external view returns (string memory); + + /// @notice The URI for the contract + function contractURI() external view returns (string memory); + + /// @notice The total supply of tokens + function totalSupply() external view returns (uint256); + + /// @notice The token's auction house + function auction() external view returns (address); + + /// @notice The token's metadata renderer + function metadataRenderer() external view returns (address); + + /// @notice The owner of the token and metadata renderer + function owner() external view returns (address); + + /// @notice Check if an address is a minter + /// @param _minter Address to check + function isMinter(address _minter) external view returns (bool); + + /// @notice Callback called by auction on first auction started to transfer ownership to treasury from founder + function onFirstAuctionStarted() external; +} diff --git a/src/token/metadata/MetadataRenderer.sol b/src/token/metadata/MetadataRenderer.sol index c651a25..97d7dab 100644 --- a/src/token/metadata/MetadataRenderer.sol +++ b/src/token/metadata/MetadataRenderer.sol @@ -14,7 +14,7 @@ import { ERC721 } from "../../lib/token/ERC721.sol"; import { MetadataRendererStorageV1 } from "./storage/MetadataRendererStorageV1.sol"; import { MetadataRendererStorageV2 } from "./storage/MetadataRendererStorageV2.sol"; -import { IToken } from "../../token/IToken.sol"; +import { IToken } from "../../token/default/IToken.sol"; import { IPropertyIPFSMetadataRenderer } from "./interfaces/IPropertyIPFSMetadataRenderer.sol"; import { IManager } from "../../manager/IManager.sol"; import { IBaseMetadata } from "./interfaces/IBaseMetadata.sol"; diff --git a/src/token/partial-soulbound/IPartialSoulboundToken.sol b/src/token/partial-soulbound/IPartialSoulboundToken.sol new file mode 100644 index 0000000..f9afb93 --- /dev/null +++ b/src/token/partial-soulbound/IPartialSoulboundToken.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.16; + +import { IUUPS } from "../../lib/interfaces/IUUPS.sol"; +import { IERC721Votes } from "../../lib/interfaces/IERC721Votes.sol"; +import { IManager } from "../../manager/IManager.sol"; +import { IBaseToken } from "../interfaces/IBaseToken.sol"; +import { PartialSoulboundTokenTypesV1 } from "./types/PartialSoulboundTokenTypesV1.sol"; + +/// @title IToken +/// @author Neokry +/// @notice The external Token events, errors and functions +interface IPartialSoulboundToken is IUUPS, IERC721Votes, IBaseToken, PartialSoulboundTokenTypesV1 { + /// /// + /// EVENTS /// + /// /// + + /// @notice Emitted when a token is scheduled to be allocated + /// @param baseTokenId The + /// @param founderId The founder's id + /// @param founder The founder's vesting details + event MintScheduled(uint256 baseTokenId, uint256 founderId, Founder founder); + + /// @notice Emitted when a token allocation is unscheduled (removed) + /// @param baseTokenId The token ID % 100 + /// @param founderId The founder's id + /// @param founder The founder's vesting details + event MintUnscheduled(uint256 baseTokenId, uint256 founderId, Founder founder); + + /// @notice Emitted when a tokens founders are deleted from storage + /// @param newFounders the list of founders + event FounderAllocationsCleared(IManager.FounderParams[] newFounders); + + /// @notice Emitted when minters are updated + /// @param minter Address of added or removed minter + /// @param allowed Whether address is allowed to mint + event MinterUpdated(address minter, bool allowed); + + /// /// + /// ERRORS /// + /// /// + + /// @dev Reverts if the founder ownership exceeds 100 percent + error INVALID_FOUNDER_OWNERSHIP(); + + /// @dev Reverts if the caller was not the auction contract + error ONLY_AUCTION(); + + /// @dev Reverts if the caller was not a minter + error ONLY_AUCTION_OR_MINTER(); + + /// @dev Reverts if the caller was not the token owner + error ONLY_TOKEN_OWNER(); + + /// @dev Reverts if no metadata was generated upon mint + error NO_METADATA_GENERATED(); + + /// @dev Reverts if the caller was not the contract manager + error ONLY_MANAGER(); + + /// /// + /// STRUCTS /// + /// /// + + struct TokenParams { + string name; + string symbol; + } + + /// /// + /// FUNCTIONS /// + /// /// + + /// @notice Initializes a DAO's ERC-721 token + /// @param founders The founding members to receive vesting allocations + /// @param data The encoded token and metadata initialization strings + /// @param metadataRenderer The token's metadata renderer + /// @param auction The token's auction house + function initialize( + IManager.FounderParams[] calldata founders, + bytes calldata data, + address metadataRenderer, + address auction, + address initialOwner + ) external; + + /// @notice Mints tokens to the caller and handles founder vesting + function mint() external returns (uint256 tokenId); + + /// @notice Mints tokens to the recipient and handles founder vesting + function mintTo(address recipient) external returns (uint256 tokenId); + + /// @notice Mints the specified amount of tokens to the recipient and handles founder vesting + function mintBatchTo(uint256 amount, address recipient) external returns (uint256[] memory tokenIds); + + /// @notice Burns a token owned by the caller + /// @param tokenId The ERC-721 token id + function burn(uint256 tokenId) external; + + /// @notice The URI for a token + /// @param tokenId The ERC-721 token id + function tokenURI(uint256 tokenId) external view returns (string memory); + + /// @notice The URI for the contract + function contractURI() external view returns (string memory); + + /// @notice The number of founders + function totalFounders() external view returns (uint256); + + /// @notice The founders total percent ownership + function totalFounderOwnership() external view returns (uint256); + + /// @notice The vesting details of a founder + /// @param founderId The founder id + function getFounder(uint256 founderId) external view returns (Founder memory); + + /// @notice The vesting details of all founders + function getFounders() external view returns (Founder[] memory); + + /// @notice Update the list of allocation owners + /// @param newFounders the full list of FounderParam structs + function updateFounders(IManager.FounderParams[] calldata newFounders) external; + + /// @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); + + /// @notice The total supply of tokens + function totalSupply() external view returns (uint256); + + /// @notice The token's auction house + function auction() external view returns (address); + + /// @notice The token's metadata renderer + function metadataRenderer() external view returns (address); + + /// @notice The owner of the token and metadata renderer + function owner() external view returns (address); + + /// @notice Update minters + /// @param _minters Array of structs containing address status as a minter + function updateMinters(MinterParams[] calldata _minters) external; + + /// @notice Check if an address is a minter + /// @param _minter Address to check + function isMinter(address _minter) external view returns (bool); + + /// @notice Callback called by auction on first auction started to transfer ownership to treasury from founder + function onFirstAuctionStarted() external; +} diff --git a/src/token/partial-soulbound/PartialSoulboundToken.sol b/src/token/partial-soulbound/PartialSoulboundToken.sol new file mode 100644 index 0000000..a88e24c --- /dev/null +++ b/src/token/partial-soulbound/PartialSoulboundToken.sol @@ -0,0 +1,479 @@ +// SPDX-License-Identifier: MIT +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 { PartialSoulboundTokenStorageV1 } from "./storage/PartialSoulboundTokenStorageV1.sol"; +import { IBaseMetadata } from "../metadata/interfaces/IBaseMetadata.sol"; +import { IManager } from "../../manager/IManager.sol"; +import { IAuction } from "../../auction/IAuction.sol"; +import { IPartialSoulboundToken } from "./IPartialSoulboundToken.sol"; +import { IBaseToken } from "../interfaces/IBaseToken.sol"; +import { VersionedContract } from "../../VersionedContract.sol"; + +/// @title Token +/// @author Neokry +/// @custom:repo github.com/ourzora/nouns-protocol +/// @notice A DAO's ERC-721 governance token +contract PartialSoulboundToken is + IPartialSoulboundToken, + VersionedContract, + UUPS, + Ownable, + ReentrancyGuard, + ERC721Votes, + PartialSoulboundTokenStorageV1 +{ + /// /// + /// IMMUTABLES /// + /// /// + + /// @notice The contract upgrade manager + IManager private immutable manager; + + /// /// + /// MODIFIERS /// + /// /// + + /// @notice Reverts if caller is not an authorized minter + modifier onlyAuctionOrMinter() { + if (msg.sender != settings.auction && !minter[msg.sender]) { + revert ONLY_AUCTION_OR_MINTER(); + } + + _; + } + + /// /// + /// 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 _data The encoded token initialization parameters + /// @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 _data, + 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 + IPartialSoulboundToken.TokenParams memory params = abi.decode(_data, (IPartialSoulboundToken.TokenParams)); + + // Initialize the ERC-721 token + __ERC721_init(params.name, params.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(); + } + + // 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 { + // Used to store the total percent ownership among the founders + uint256 totalOwnership; + + uint8 numFoundersAdded = 0; + + unchecked { + // For each founder: + for (uint256 i; i < _founders.length; ++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 = numFoundersAdded++; + + // 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 = numFoundersAdded; + } + } + + /// @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 caller and handles founder vesting + function mint() external nonReentrant onlyAuctionOrMinter returns (uint256 tokenId) { + tokenId = _mintWithVesting(msg.sender); + } + + /// @notice Mints tokens to the recipient and handles founder vesting + function mintTo(address recipient) external nonReentrant onlyAuctionOrMinter returns (uint256 tokenId) { + tokenId = _mintWithVesting(recipient); + } + + /// @notice Mints the specified amount of tokens to the recipient and handles founder vesting + function mintBatchTo(uint256 amount, address recipient) external nonReentrant onlyAuctionOrMinter returns (uint256[] memory tokenIds) { + tokenIds = new uint256[](amount); + for (uint256 i = 0; i < amount; ) { + tokenIds[i] = _mintWithVesting(recipient); + unchecked { + ++i; + } + } + } + + function _mintWithVesting(address recipient) internal returns (uint256 tokenId) { + // Cannot realistically overflow + unchecked { + do { + // Get the next token to mint + tokenId = 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 recipient for bidding + _mint(recipient, 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 owned by the caller + /// @param _tokenId The ERC-721 token id + function burn(uint256 _tokenId) external onlyAuctionOrMinter { + if (ownerOf(_tokenId) != msg.sender) { + revert ONLY_TOKEN_OWNER(); + } + + _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(IPartialSoulboundToken, ERC721) returns (string memory) { + return settings.metadataRenderer.tokenURI(_tokenId); + } + + /// @notice The URI for the contract + function contractURI() public view override(IPartialSoulboundToken, 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]; + + // Delete the founder from the stored mapping + delete founder[i]; + + // Some DAOs were initialized with 0 percentage ownership. + // This skips them to avoid a division by zero error. + if (cachedFounder.ownershipPct == 0) { + continue; + } + + // 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; + } + } + } + + 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(IPartialSoulboundToken, Ownable) returns (address) { + return super.owner(); + } + + /// @notice Update minters + /// @param _minters Array of structs containing address status as a minter + function updateMinters(MinterParams[] calldata _minters) external onlyOwner { + // Update each minter + for (uint256 i; i < _minters.length; ++i) { + // Skip if the minter is already set to the correct value + if (minter[_minters[i].minter] == _minters[i].allowed) continue; + + emit MinterUpdated(_minters[i].minter, _minters[i].allowed); + + // Update the minter + minter[_minters[i].minter] = _minters[i].allowed; + } + } + + /// @notice Check if an address is a minter + /// @param _minter Address to check + function isMinter(address _minter) external view returns (bool) { + return minter[_minter]; + } + + /// /// + /// 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/partial-soulbound/storage/PartialSoulboundTokenStorageV1.sol b/src/token/partial-soulbound/storage/PartialSoulboundTokenStorageV1.sol new file mode 100644 index 0000000..d37f4f6 --- /dev/null +++ b/src/token/partial-soulbound/storage/PartialSoulboundTokenStorageV1.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.16; + +import { PartialSoulboundTokenTypesV1 } from "../types/PartialSoulboundTokenTypesV1.sol"; + +/// @title PartialSoulboundTokenStorageV1 +/// @author Neokry +/// @notice The Token storage contract +contract PartialSoulboundTokenStorageV1 is PartialSoulboundTokenTypesV1 { + /// @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; + + /// @notice The minter status of an address + mapping(address => bool) public minter; +} diff --git a/src/token/partial-soulbound/types/PartialSoulboundTokenTypesV1.sol b/src/token/partial-soulbound/types/PartialSoulboundTokenTypesV1.sol new file mode 100644 index 0000000..3d4a8ce --- /dev/null +++ b/src/token/partial-soulbound/types/PartialSoulboundTokenTypesV1.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.16; + +import { IBaseMetadata } from "../../metadata/interfaces/IBaseMetadata.sol"; + +/// @title PartialSoulboundTokenTypesV1 +/// @author Neokry +/// @notice The Token custom data types +interface PartialSoulboundTokenTypesV1 { + /// @notice The settings type + /// @param auction The DAO auction house + /// @param totalSupply The number of active tokens + /// @param numFounders The number of vesting recipients + /// @param metadatarenderer The token metadata renderer + /// @param mintCount The number of minted tokens + /// @param totalPercentage The total percentage owned by founders + struct Settings { + address auction; + uint88 totalSupply; + uint8 numFounders; + IBaseMetadata metadataRenderer; + uint88 mintCount; + uint8 totalOwnership; + } + + /// @notice The founder type + /// @param wallet The address where tokens are sent + /// @param ownershipPct The percentage of token ownership + /// @param vestExpiry The timestamp when vesting ends + struct Founder { + address wallet; + uint8 ownershipPct; + uint32 vestExpiry; + } + + struct MinterParams { + address minter; + bool allowed; + } +} diff --git a/test/Token.t.sol b/test/Token.t.sol index 2606b24..7589424 100644 --- a/test/Token.t.sol +++ b/test/Token.t.sol @@ -4,9 +4,9 @@ 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 { TokenTypesV1 } from "../src/token/types/TokenTypesV1.sol"; -import { TokenTypesV2 } from "../src/token/types/TokenTypesV2.sol"; +import { IToken, Token } from "../src/token/default/Token.sol"; +import { TokenTypesV1 } from "../src/token/default/types/TokenTypesV1.sol"; +import { TokenTypesV2 } from "../src/token/default/types/TokenTypesV2.sol"; contract TokenTest is NounsBuilderTest, TokenTypesV1 { mapping(address => uint256) public mintedTokens; diff --git a/test/forking/TestBid.t.sol b/test/forking/TestBid.t.sol index 568de98..d3a9ea7 100644 --- a/test/forking/TestBid.t.sol +++ b/test/forking/TestBid.t.sol @@ -5,7 +5,7 @@ import { Test } from "forge-std/Test.sol"; import { Treasury } from "../../src/governance/treasury/Treasury.sol"; import { Auction } from "../../src/auction/Auction.sol"; import { IAuction } from "../../src/auction/IAuction.sol"; -import { Token } from "../../src/token/Token.sol"; +import { Token } from "../../src/token/default/Token.sol"; import { Governor } from "../../src/governance/governor/Governor.sol"; import { IManager } from "../../src/manager/IManager.sol"; import { Manager } from "../../src/manager/Manager.sol"; diff --git a/test/forking/TestUpdateMinters.t.sol b/test/forking/TestUpdateMinters.t.sol index 05e7868..5dbe4cf 100644 --- a/test/forking/TestUpdateMinters.t.sol +++ b/test/forking/TestUpdateMinters.t.sol @@ -5,13 +5,13 @@ import { Test } from "forge-std/Test.sol"; import { Treasury } from "../../src/governance/treasury/Treasury.sol"; import { Auction } from "../../src/auction/Auction.sol"; import { IAuction } from "../../src/auction/IAuction.sol"; -import { Token } from "../../src/token/Token.sol"; +import { Token } from "../../src/token/default/Token.sol"; import { MetadataRenderer } from "../../src/token/metadata/MetadataRenderer.sol"; import { Governor } from "../../src/governance/governor/Governor.sol"; import { IManager } from "../../src/manager/IManager.sol"; import { Manager } from "../../src/manager/Manager.sol"; import { UUPS } from "../../src/lib/proxy/UUPS.sol"; -import { TokenTypesV2 } from "../../src/token/types/TokenTypesV2.sol"; +import { TokenTypesV2 } from "../../src/token/default/types/TokenTypesV2.sol"; import { GovernorTypesV1 } from "../../src/governance/governor/types/GovernorTypesV1.sol"; contract TestUpdateMinters is Test { diff --git a/test/forking/TestUpdateOwners.t.sol b/test/forking/TestUpdateOwners.t.sol index cf58d5d..8107c9d 100644 --- a/test/forking/TestUpdateOwners.t.sol +++ b/test/forking/TestUpdateOwners.t.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.16; import { Test } from "forge-std/Test.sol"; import { Treasury } from "../../src/governance/treasury/Treasury.sol"; import { Auction } from "../../src/auction/Auction.sol"; -import { Token } from "../../src/token/Token.sol"; +import { Token } from "../../src/token/default/Token.sol"; import { Governor } from "../../src/governance/governor/Governor.sol"; import { IManager } from "../../src/manager/IManager.sol"; import { Manager } from "../../src/manager/Manager.sol"; @@ -35,19 +35,19 @@ contract PurpleTests is Test { IManager.FounderParams[] memory newFounderParams = new IManager.FounderParams[](3); newFounderParams[0] = IManager.FounderParams({ - wallet: address(0x06B59d0b6AdCc6A5Dc63553782750dc0b41266a3), - ownershipPct: 10, - vestExpiry:2556057600 + wallet: address(0x06B59d0b6AdCc6A5Dc63553782750dc0b41266a3), + ownershipPct: 10, + vestExpiry: 2556057600 }); newFounderParams[1] = IManager.FounderParams({ - wallet: address(0x349993989b5AC27Fd033AcCb86a84920DEb91ABa), - ownershipPct: 10, - vestExpiry:2556057600 + wallet: address(0x349993989b5AC27Fd033AcCb86a84920DEb91ABa), + ownershipPct: 10, + vestExpiry: 2556057600 }); newFounderParams[2] = IManager.FounderParams({ - wallet: address(0x0BC3807Ec262cB779b38D65b38158acC3bfedE10), - ownershipPct: 1, - vestExpiry: 2556057600 + wallet: address(0x0BC3807Ec262cB779b38D65b38158acC3bfedE10), + ownershipPct: 1, + vestExpiry: 2556057600 }); targets = new address[](2); @@ -76,7 +76,5 @@ contract PurpleTests is Test { vm.warp(block.timestamp + 3 days); governor.execute(targets, values, calldatas, keccak256(""), fawkes); - - } -} \ No newline at end of file +} diff --git a/test/utils/NounsBuilderTest.sol b/test/utils/NounsBuilderTest.sol index 5c53d3f..e14e18a 100644 --- a/test/utils/NounsBuilderTest.sol +++ b/test/utils/NounsBuilderTest.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.16; import { Test } from "forge-std/Test.sol"; import { IManager, Manager } from "../../src/manager/Manager.sol"; -import { IToken, Token } from "../../src/token/Token.sol"; +import { IToken, Token } from "../../src/token/default/Token.sol"; import { IBaseMetadata, MetadataRenderer } from "../../src/token/metadata/MetadataRenderer.sol"; import { IAuction, Auction } from "../../src/auction/Auction.sol"; import { IGovernor, Governor } from "../../src/governance/governor/Governor.sol"; From c52a9e3ca2c0b5e550fd0a18e6067f5d6a8f693e Mon Sep 17 00:00:00 2001 From: neokry Date: Tue, 8 Aug 2023 16:52:24 +0900 Subject: [PATCH 2/4] Finish partial soulbound implementation --- .../IPartialSoulboundToken.sol | 13 ++- .../PartialSoulboundToken.sol | 86 +++++++++++++++++-- .../PartialSoulboundTokenStorageV1.sol | 7 ++ 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/src/token/partial-soulbound/IPartialSoulboundToken.sol b/src/token/partial-soulbound/IPartialSoulboundToken.sol index f9afb93..559da75 100644 --- a/src/token/partial-soulbound/IPartialSoulboundToken.sol +++ b/src/token/partial-soulbound/IPartialSoulboundToken.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.16; import { IUUPS } from "../../lib/interfaces/IUUPS.sol"; import { IERC721Votes } from "../../lib/interfaces/IERC721Votes.sol"; +import { IERC5192 } from "../../lib/interfaces/IERC5192.sol"; import { IManager } from "../../manager/IManager.sol"; import { IBaseToken } from "../interfaces/IBaseToken.sol"; import { PartialSoulboundTokenTypesV1 } from "./types/PartialSoulboundTokenTypesV1.sol"; @@ -10,7 +11,7 @@ import { PartialSoulboundTokenTypesV1 } from "./types/PartialSoulboundTokenTypes /// @title IToken /// @author Neokry /// @notice The external Token events, errors and functions -interface IPartialSoulboundToken is IUUPS, IERC721Votes, IBaseToken, PartialSoulboundTokenTypesV1 { +interface IPartialSoulboundToken is IUUPS, IERC721Votes, IBaseToken, IERC5192, PartialSoulboundTokenTypesV1 { /// /// /// EVENTS /// /// /// @@ -58,6 +59,15 @@ interface IPartialSoulboundToken is IUUPS, IERC721Votes, IBaseToken, PartialSoul /// @dev Reverts if the caller was not the contract manager error ONLY_MANAGER(); + /// @dev Reverts if the token is not reserved + error TOKEN_NOT_RESERVED(); + + /// @dev Reverts if the token is locked + error TOKEN_LOCKED(); + + /// @dev Reverts if the token is lockable + error TOKEN_NOT_LOCKABLE(); + /// /// /// STRUCTS /// /// /// @@ -65,6 +75,7 @@ interface IPartialSoulboundToken is IUUPS, IERC721Votes, IBaseToken, PartialSoul struct TokenParams { string name; string symbol; + uint256 reservedUntilTokenId; } /// /// diff --git a/src/token/partial-soulbound/PartialSoulboundToken.sol b/src/token/partial-soulbound/PartialSoulboundToken.sol index a88e24c..78a4929 100644 --- a/src/token/partial-soulbound/PartialSoulboundToken.sol +++ b/src/token/partial-soulbound/PartialSoulboundToken.sol @@ -14,6 +14,8 @@ import { IPartialSoulboundToken } from "./IPartialSoulboundToken.sol"; import { IBaseToken } from "../interfaces/IBaseToken.sol"; import { VersionedContract } from "../../VersionedContract.sol"; +import { BitMaps } from "@openzeppelin/contracts/utils/structs/BitMaps.sol"; + /// @title Token /// @author Neokry /// @custom:repo github.com/ourzora/nouns-protocol @@ -27,6 +29,8 @@ contract PartialSoulboundToken is ERC721Votes, PartialSoulboundTokenStorageV1 { + using BitMaps for BitMaps.BitMap; + /// /// /// IMMUTABLES /// /// /// @@ -38,6 +42,15 @@ contract PartialSoulboundToken is /// MODIFIERS /// /// /// + /// @notice Reverts if caller is not an authorized minter + modifier onlyMinter() { + if (!minter[msg.sender]) { + revert ONLY_AUCTION_OR_MINTER(); + } + + _; + } + /// @notice Reverts if caller is not an authorized minter modifier onlyAuctionOrMinter() { if (msg.sender != settings.auction && !minter[msg.sender]) { @@ -84,18 +97,19 @@ contract PartialSoulboundToken is // Setup ownable __Ownable_init(_initialOwner); - // Store the founders and compute their allocations - _addFounders(_founders); - // Decode the token name and symbol IPartialSoulboundToken.TokenParams memory params = abi.decode(_data, (IPartialSoulboundToken.TokenParams)); + // Store the founders and compute their allocations + _addFounders(_founders, params.reservedUntilTokenId); + // Initialize the ERC-721 token __ERC721_init(params.name, params.symbol); // Store the metadata renderer and auction house settings.metadataRenderer = IBaseMetadata(_metadataRenderer); settings.auction = _auction; + reservedUntilTokenId = params.reservedUntilTokenId; } /// @notice Called by the auction upon the first unpause / token mint to transfer ownership from founder to treasury @@ -112,7 +126,7 @@ contract PartialSoulboundToken is /// @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 { + function _addFounders(IManager.FounderParams[] calldata _founders, uint256 reservedUntilTokenId) internal { // Used to store the total percent ownership among the founders uint256 totalOwnership; @@ -153,7 +167,7 @@ contract PartialSoulboundToken is uint256 schedule = 100 / founderPct; // Used to store the base token id the founder will recieve - uint256 baseTokenId; + uint256 baseTokenId = reservedUntilTokenId; // For each token to vest: for (uint256 j; j < founderPct; ++j) { @@ -202,6 +216,22 @@ contract PartialSoulboundToken is tokenId = _mintWithVesting(recipient); } + /// @notice Mints tokens from the reserve to the recipient + function mintFromReserveTo(address recipient, uint256 tokenId) external nonReentrant onlyMinter { + if (tokenId >= reservedUntilTokenId) revert TOKEN_NOT_RESERVED(); + _mint(recipient, tokenId); + } + + /// @notice Mints a token from the reserve and locks to the recipient + function mintFromReserveAndLockTo(address recipient, uint256 tokenId) external nonReentrant onlyMinter { + if (tokenId >= reservedUntilTokenId) revert TOKEN_NOT_RESERVED(); + + _mint(recipient, tokenId); + _lock(tokenId); + + emit Locked(tokenId); + } + /// @notice Mints the specified amount of tokens to the recipient and handles founder vesting function mintBatchTo(uint256 amount, address recipient) external nonReentrant onlyAuctionOrMinter returns (uint256[] memory tokenIds) { tokenIds = new uint256[](amount); @@ -218,7 +248,7 @@ contract PartialSoulboundToken is unchecked { do { // Get the next token to mint - tokenId = settings.mintCount++; + tokenId = reservedUntilTokenId + settings.mintCount++; // Lookup whether the token is for a founder, and mint accordingly if so } while (_isForFounder(tokenId)); @@ -292,6 +322,35 @@ contract PartialSoulboundToken is } } + /// /// + /// LOCK /// + /// /// + + function transferFromAndLock( + address from, + address to, + uint256 tokenId + ) external nonReentrant { + if (tokenId >= reservedUntilTokenId) revert TOKEN_NOT_LOCKABLE(); + + super.transferFrom(from, to, tokenId); + _lock(tokenId); + + emit Locked(tokenId); + } + + function locked(uint256 tokenId) external view returns (bool) { + return _locked(tokenId); + } + + function _lock(uint256 tokenId) internal { + isTokenLockedBitMap.set(tokenId); + } + + function _locked(uint256 tokenId) internal view returns (bool) { + return isTokenLockedBitMap.get(tokenId); + } + /// /// /// METADATA /// /// /// @@ -415,7 +474,7 @@ contract PartialSoulboundToken is settings.totalOwnership = 0; emit FounderAllocationsCleared(newFounders); - _addFounders(newFounders); + _addFounders(newFounders, reservedUntilTokenId); } /// /// @@ -462,6 +521,19 @@ contract PartialSoulboundToken is return minter[_minter]; } + /// /// + /// BEFORE TRANSFER OVERRIDE /// + /// /// + + function _beforeTokenTransfer( + address from, + address to, + uint256 tokenId + ) internal virtual override(ERC721) { + super._beforeTokenTransfer(from, to, tokenId); + if (_locked(tokenId)) revert TOKEN_LOCKED(); + } + /// /// /// TOKEN UPGRADE /// /// /// diff --git a/src/token/partial-soulbound/storage/PartialSoulboundTokenStorageV1.sol b/src/token/partial-soulbound/storage/PartialSoulboundTokenStorageV1.sol index d37f4f6..0a896e0 100644 --- a/src/token/partial-soulbound/storage/PartialSoulboundTokenStorageV1.sol +++ b/src/token/partial-soulbound/storage/PartialSoulboundTokenStorageV1.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.16; import { PartialSoulboundTokenTypesV1 } from "../types/PartialSoulboundTokenTypesV1.sol"; +import { BitMaps } from "@openzeppelin/contracts/utils/structs/BitMaps.sol"; /// @title PartialSoulboundTokenStorageV1 /// @author Neokry @@ -20,4 +21,10 @@ contract PartialSoulboundTokenStorageV1 is PartialSoulboundTokenTypesV1 { /// @notice The minter status of an address mapping(address => bool) public minter; + + /// @notice Marks the first n tokens as reserved + uint256 public reservedUntilTokenId; + + /// @notice ERC-721 token id => locked + BitMaps.BitMap internal isTokenLockedBitMap; } From f6fa780ca60e3ca72d2d61560a8a5d3c83f2c00a Mon Sep 17 00:00:00 2001 From: neokry Date: Fri, 18 Aug 2023 16:57:00 +0900 Subject: [PATCH 3/4] Add unit tests --- test/PartialSoulboundToken.t.sol | 1032 ++++++++++++++++++++++++++++++ test/Token.t.sol | 4 +- 2 files changed, 1034 insertions(+), 2 deletions(-) create mode 100644 test/PartialSoulboundToken.t.sol diff --git a/test/PartialSoulboundToken.t.sol b/test/PartialSoulboundToken.t.sol new file mode 100644 index 0000000..723cb7e --- /dev/null +++ b/test/PartialSoulboundToken.t.sol @@ -0,0 +1,1032 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.16; + +import { NounsBuilderTest } from "./utils/NounsBuilderTest.sol"; +import { PartialSoulboundToken } from "../src/token/partial-soulbound/PartialSoulboundToken.sol"; + +import { IManager, Manager } from "../src/manager/Manager.sol"; +import { IToken, Token } from "../src/token/default/Token.sol"; +import { TokenTypesV1 } from "../src/token/default/types/TokenTypesV1.sol"; +import { TokenTypesV2 } from "../src/token/default/types/TokenTypesV2.sol"; + +contract PartialSoulboundTokenTest is NounsBuilderTest, TokenTypesV1 { + mapping(address => uint256) public mintedTokens; + + PartialSoulboundToken soulboundToken; + address soulboundTokenImpl; + + function setUp() public virtual override { + super.setUp(); + } + + function deployAltMock(uint256 _reservedUntilTokenId) internal virtual { + setMockFounderParams(); + + setMockTokenParamsWithReserve(_reservedUntilTokenId); + + setMockAuctionParams(); + + setMockGovParams(); + + setImplementationAddresses(); + + soulboundTokenImpl = address(new PartialSoulboundToken(address(manager))); + + vm.startPrank(zoraDAO); + manager.registerImplementation(manager.IMPLEMENTATION_TYPE_TOKEN(), soulboundTokenImpl); + vm.stopPrank(); + + implAddresses[manager.IMPLEMENTATION_TYPE_TOKEN()] = soulboundTokenImpl; + + deploy(foundersArr, implAddresses, implData); + + soulboundToken = PartialSoulboundToken(address(token)); + + setMockMetadata(); + } + + function test_MockTokenInit() public { + deployAltMock(0); + + 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); + + 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 { + deployAltMock(0); + + 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 { + deployAltMock(0); + + 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); + + // Last founder is omitted so total number of founders is 99 + assertEq(token.totalFounders(), 99); + assertEq(token.totalFounderOwnership(), 99); + + Founder memory thisFounder; + + for (uint256 i; i < 99; ++i) { + thisFounder = token.getScheduledRecipient(i); + + assertEq(thisFounder.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); + + assertEq(token.totalFounders(), 50); + assertEq(token.totalFounderOwnership(), 99); + + Founder memory thisFounder; + + for (uint256 i; i < 49; ++i) { + thisFounder = token.getScheduledRecipient(i); + + assertEq(thisFounder.wallet, otherUsers[i]); + + thisFounder = token.getScheduledRecipient(i + 50); + + assertEq(thisFounder.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); + + assertEq(token.totalFounders(), 2); + assertEq(token.totalFounderOwnership(), 98); + + Founder memory thisFounder; + + unchecked { + for (uint256 i; i < 500; ++i) { + thisFounder = token.getScheduledRecipient(i); + + if (i % 100 >= 98) { + continue; + } + + if (i % 2 == 0) { + assertEq(thisFounder.wallet, otherUsers[0]); + } else { + assertEq(thisFounder.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 { + deployAltMock(0); + + // 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); + } + + 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); + } + + function test_OverwriteCheckpointWithSameTimestamp() public { + deployAltMock(0); + + 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 test_AuctionCanMintAfterDeploy() public { + deployAltMock(0); + + vm.prank(founder); + auction.unpause(); + + vm.expectRevert(abi.encodeWithSignature("ONLY_AUCTION_OR_MINTER()")); + token.mint(); + + vm.prank(address(auction)); + uint256 tokenId = token.mint(); + assertEq(token.ownerOf(tokenId), address(auction)); + } + + function test_MinterCanMintBatch() public { + deployAltMock(0); + + vm.prank(founder); + auction.unpause(); + + vm.prank(address(auction)); + uint256[] memory tokenIds = token.mintBatchTo(uint256(10), address(0x1)); + assertEq(tokenIds.length, 10); + for (uint256 i = 0; i < 10; i++) { + assertEq(token.ownerOf(tokenIds[i]), address(0x1)); + } + } + + function test_MintBatch(uint8 amount, address recipient) public { + deployAltMock(0); + + vm.assume(amount > 0 && amount < 100 && recipient != address(0) && recipient != address(auction)); + vm.prank(founder); + auction.unpause(); + + vm.prank(address(auction)); + uint256[] memory tokenIds = token.mintBatchTo(amount, recipient); + assertEq(tokenIds.length, amount); + for (uint256 i = 0; i < amount; i++) { + assertEq(token.ownerOf(tokenIds[i]), address(recipient)); + } + } + + function testRevert_OnlyMinterCanMint(address newMinter, address nonMinter) public { + vm.assume(newMinter != nonMinter && newMinter != founder && newMinter != address(0) && newMinter != address(auction)); + deployAltMock(0); + + TokenTypesV2.MinterParams memory params = TokenTypesV2.MinterParams({ minter: newMinter, allowed: true }); + TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](1); + minters[0] = params; + vm.prank(address(founder)); + token.updateMinters(minters); + + vm.expectRevert(abi.encodeWithSignature("ONLY_AUCTION_OR_MINTER()")); + vm.prank(nonMinter); + token.mint(); + vm.prank(newMinter); + uint256 tokenId = token.mint(); + assertEq(token.ownerOf(tokenId), newMinter); + } + + function testRevert_OnlyMinterCanMintToRecipient( + address newMinter, + address nonMinter, + address recipient + ) public { + vm.assume( + newMinter != nonMinter && newMinter != founder && newMinter != address(0) && newMinter != address(auction) && recipient != address(0) + ); + deployAltMock(0); + + TokenTypesV2.MinterParams memory params = TokenTypesV2.MinterParams({ minter: newMinter, allowed: true }); + TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](1); + minters[0] = params; + vm.prank(address(founder)); + token.updateMinters(minters); + + vm.expectRevert(abi.encodeWithSignature("ONLY_AUCTION_OR_MINTER()")); + vm.prank(nonMinter); + token.mintTo(recipient); + vm.prank(newMinter); + uint256 tokenId = token.mintTo(recipient); + assertEq(token.ownerOf(tokenId), recipient); + } + + function testRevert_OnlyMinterCanMintBatch( + address newMinter, + address nonMinter, + address recipient, + uint256 amount + ) public { + vm.assume( + newMinter != nonMinter && + newMinter != founder && + newMinter != address(0) && + newMinter != address(auction) && + recipient != address(0) && + amount > 0 && + amount < 100 + ); + deployAltMock(0); + + TokenTypesV2.MinterParams memory params = TokenTypesV2.MinterParams({ minter: newMinter, allowed: true }); + TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](1); + minters[0] = params; + vm.prank(address(founder)); + token.updateMinters(minters); + + vm.expectRevert(abi.encodeWithSignature("ONLY_AUCTION_OR_MINTER()")); + vm.prank(nonMinter); + token.mintTo(recipient); + vm.prank(newMinter); + uint256 tokenId = token.mintTo(recipient); + assertEq(token.ownerOf(tokenId), recipient); + } + + function testRevert_OnlyDAOCanUpgrade() public { + deployAltMock(0); + + vm.prank(founder); + auction.unpause(); + + vm.expectRevert(abi.encodeWithSignature("ONLY_OWNER()")); + token.upgradeTo(address(this)); + } + + function testRevert_OnlyDAOCanUpgradeToAndCall() public { + deployAltMock(0); + + 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); + + assertEq(token.totalFounders(), 2); + assertEq(token.totalFounderOwnership(), 99); + + Founder memory thisFounder; + + unchecked { + for (uint256 i; i < 99; ++i) { + thisFounder = token.getScheduledRecipient(i); + + if (i % 2 == 0) { + assertEq(thisFounder.wallet, otherUsers[0]); + } else { + assertEq(thisFounder.wallet, otherUsers[1]); + } + } + } + + vm.prank(otherUsers[0]); + auction.unpause(); + } + + function testFoundersCreateZeroOwnershipOmitted() 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] = 0; + percents[1] = 50; + + deployWithCustomFounders(wallets, percents, vestExpirys); + + assertEq(token.totalFounders(), 1); + assertEq(token.totalFounderOwnership(), 50); + + unchecked { + for (uint256 i; i < 99; ++i) { + if (i % 2 == 0) { + Founder memory thisFounder = token.getScheduledRecipient(i); + assertEq(thisFounder.wallet, otherUsers[1]); + } + } + } + + vm.prank(otherUsers[0]); + auction.unpause(); + } + + function testRevert_OnlyOwnerUpdateFounders() public { + deployAltMock(0); + + 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_UpdateFoundersZeroOwnership() public { + deployAltMock(0); + + IManager.FounderParams[] memory newFoundersArr = new IManager.FounderParams[](2); + newFoundersArr[0] = IManager.FounderParams({ + wallet: address(0x06B59d0b6AdCc6A5Dc63553782750dc0b41266a3), + ownershipPct: 0, + vestExpiry: 2556057600 + }); + newFoundersArr[1] = IManager.FounderParams({ + wallet: address(0x06B59d0b6AdCc6A5Dc63553782750dc0b41266a3), + ownershipPct: 10, + vestExpiry: 2556057600 + }); + + vm.prank(address(founder)); + token.updateFounders(newFoundersArr); + + assertEq(token.getFounders().length, 1); + } + + function test_UpdateFounderShareAllocationFuzz( + uint256 f1Percentage, + uint256 f2Percentage, + uint256 f3Percentage + ) public { + deployAltMock(0); + + 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); + } + + function test_UpdateMintersOwnerCanAddMinters(address m1, address m2) public { + vm.assume( + m1 != founder && m1 != address(0) && m1 != address(auction) && m2 != founder && m2 != address(0) && m2 != address(auction) && m1 != m2 + ); + + deployAltMock(0); + + TokenTypesV2.MinterParams memory p1 = TokenTypesV2.MinterParams({ minter: m1, allowed: true }); + TokenTypesV2.MinterParams memory p2 = TokenTypesV2.MinterParams({ minter: m2, allowed: true }); + TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](2); + minters[0] = p1; + minters[1] = p2; + + vm.prank(address(founder)); + token.updateMinters(minters); + + assertTrue(token.minter(minters[0].minter)); + assertTrue(token.minter(minters[1].minter)); + + vm.prank(minters[0].minter); + uint256 tokenId = token.mint(); + assertEq(token.ownerOf(tokenId), minters[0].minter); + + vm.prank(minters[1].minter); + tokenId = token.mint(); + assertEq(token.ownerOf(tokenId), minters[1].minter); + } + + function test_isMinterReturnsMinterStatus(address _minter) public { + vm.assume(_minter != founder && _minter != address(0) && _minter != address(auction)); + + deployAltMock(0); + + TokenTypesV2.MinterParams memory p = TokenTypesV2.MinterParams({ minter: _minter, allowed: true }); + TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](1); + minters[0] = p; + + vm.prank(address(founder)); + token.updateMinters(minters); + assertTrue(token.isMinter(_minter)); + + p.allowed = false; + vm.prank(address(founder)); + token.updateMinters(minters); + assertFalse(token.isMinter(_minter)); + } + + function test_UpdateMintersOwnerCanRemoveMinters(address m1, address m2) public { + vm.assume( + m1 != founder && m1 != address(0) && m1 != address(auction) && m2 != founder && m2 != address(0) && m2 != address(auction) && m1 != m2 + ); + + deployAltMock(0); + + // authorize two minters + TokenTypesV2.MinterParams memory p1 = TokenTypesV2.MinterParams({ minter: m1, allowed: true }); + TokenTypesV2.MinterParams memory p2 = TokenTypesV2.MinterParams({ minter: m2, allowed: true }); + TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](2); + minters[0] = p1; + minters[1] = p2; + + vm.prank(address(founder)); + token.updateMinters(minters); + + assertTrue(token.minter(minters[0].minter)); + assertTrue(token.minter(minters[1].minter)); + + vm.prank(minters[0].minter); + uint256 tokenId = token.mint(); + assertEq(token.ownerOf(tokenId), minters[0].minter); + + vm.prank(minters[1].minter); + tokenId = token.mint(); + assertEq(token.ownerOf(tokenId), minters[1].minter); + + // remove authorization from one minter + minters[1].allowed = false; + vm.prank(address(founder)); + token.updateMinters(minters); + + assertTrue(token.minter(minters[0].minter)); + assertTrue(!token.minter(minters[1].minter)); + + vm.prank(minters[1].minter); + vm.expectRevert(abi.encodeWithSignature("ONLY_AUCTION_OR_MINTER()")); + token.mint(); + } + + function testRevert_OnlyOwnerUpdateMinters() public { + deployAltMock(0); + + TokenTypesV2.MinterParams memory p1 = TokenTypesV2.MinterParams({ minter: address(0x1), allowed: true }); + TokenTypesV2.MinterParams memory p2 = TokenTypesV2.MinterParams({ minter: address(0x2), allowed: true }); + TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](2); + minters[0] = p1; + minters[1] = p2; + + vm.expectRevert(abi.encodeWithSignature("ONLY_OWNER()")); + token.updateMinters(minters); + } + + function test_MinterCanBurnTheirOwnToken(address newMinter) public { + vm.assume(newMinter != founder && newMinter != address(0) && newMinter != address(auction)); + + deployAltMock(0); + + TokenTypesV2.MinterParams memory p1 = TokenTypesV2.MinterParams({ minter: newMinter, allowed: true }); + TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](1); + minters[0] = p1; + + vm.prank(address(founder)); + token.updateMinters(minters); + + vm.prank(minters[0].minter); + uint256 tokenId = token.mint(); + assertEq(token.ownerOf(tokenId), minters[0].minter); + + vm.prank(minters[0].minter); + token.burn(tokenId); + vm.expectRevert(abi.encodeWithSignature("INVALID_OWNER()")); + token.ownerOf(tokenId); + } + + function test_MinterCanMintFromReserve( + address _minter, + uint256 _reservedUntilTokenId, + uint256 _tokenId + ) public { + vm.assume(_minter != founder && _minter != address(0) && _minter != address(auction)); + vm.assume(_tokenId < _reservedUntilTokenId); + deployAltMock(_reservedUntilTokenId); + + TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](1); + TokenTypesV2.MinterParams memory p1 = TokenTypesV2.MinterParams({ minter: _minter, allowed: true }); + minters[0] = p1; + + vm.prank(address(founder)); + token.updateMinters(minters); + + vm.prank(minters[0].minter); + token.mintFromReserveTo(minters[0].minter, _tokenId); + assertEq(token.ownerOf(_tokenId), minters[0].minter); + } + + function testRevert_MinterCannotMintPastReserve( + address _minter, + uint256 _reservedUntilTokenId, + uint256 _tokenId + ) public { + vm.assume(_minter != founder && _minter != address(0) && _minter != address(auction)); + vm.assume(_tokenId > _reservedUntilTokenId); + deployAltMock(_reservedUntilTokenId); + + TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](1); + TokenTypesV2.MinterParams memory p1 = TokenTypesV2.MinterParams({ minter: _minter, allowed: true }); + minters[0] = p1; + + vm.prank(address(founder)); + token.updateMinters(minters); + + vm.prank(minters[0].minter); + vm.expectRevert(abi.encodeWithSignature("TOKEN_NOT_RESERVED()")); + token.mintFromReserveTo(minters[0].minter, _tokenId); + } + + function test_SingleMintCannotMintReserves(address _minter, uint256 _reservedUntilTokenId) public { + vm.assume(_minter != founder && _minter != address(0) && _minter != address(auction)); + vm.assume(_reservedUntilTokenId > 0 && _reservedUntilTokenId < 4000); + deployAltMock(_reservedUntilTokenId); + + TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](1); + TokenTypesV2.MinterParams memory p1 = TokenTypesV2.MinterParams({ minter: _minter, allowed: true }); + minters[0] = p1; + + vm.prank(address(founder)); + token.updateMinters(minters); + + vm.prank(minters[0].minter); + uint256 tokenId = token.mint(); + assertEq(token.ownerOf(tokenId), minters[0].minter); + assertGe(tokenId, _reservedUntilTokenId); + + for (uint256 i; i < _reservedUntilTokenId; ++i) { + vm.expectRevert(); + token.ownerOf(i); + } + } + + function test_BatchMintCannotMintReserves( + address _minter, + uint256 _reservedUntilTokenId, + uint256 _amount + ) public { + vm.assume(_minter != founder && _minter != address(0) && _minter != address(auction)); + vm.assume(_reservedUntilTokenId > 0 && _reservedUntilTokenId < 4000 && _amount > 0 && _amount < 20); + deployAltMock(_reservedUntilTokenId); + + TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](1); + TokenTypesV2.MinterParams memory p1 = TokenTypesV2.MinterParams({ minter: _minter, allowed: true }); + minters[0] = p1; + + vm.prank(address(founder)); + token.updateMinters(minters); + + vm.prank(minters[0].minter); + uint256[] memory tokenIds = token.mintBatchTo(_amount, _minter); + for (uint256 i; i < tokenIds.length; ++i) { + uint256 tokenId = tokenIds[i]; + assertEq(token.ownerOf(tokenId), minters[0].minter); + assertGe(tokenId, _reservedUntilTokenId); + } + + for (uint256 i; i < _reservedUntilTokenId; ++i) { + vm.expectRevert(); + token.ownerOf(i); + } + } + + function test_MinterCanTransferAndLock( + address _minter, + address _to, + uint256 _reservedUntilTokenId, + uint256 _tokenId + ) public { + vm.assume(_minter != founder && _minter != address(0) && _minter != address(auction)); + vm.assume(_to != founder && _to != address(0) && _to != address(auction) && _to != _minter); + vm.assume(_tokenId < _reservedUntilTokenId); + deployAltMock(_reservedUntilTokenId); + + TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](1); + TokenTypesV2.MinterParams memory p1 = TokenTypesV2.MinterParams({ minter: _minter, allowed: true }); + minters[0] = p1; + + vm.prank(address(founder)); + token.updateMinters(minters); + + vm.prank(minters[0].minter); + token.mintFromReserveTo(minters[0].minter, _tokenId); + assertEq(token.ownerOf(_tokenId), minters[0].minter); + + vm.prank(minters[0].minter); + soulboundToken.transferFromAndLock(minters[0].minter, _to, _tokenId); + assertEq(token.ownerOf(_tokenId), _to); + } + + function test_CanTransferWhenNotLocked( + address _minter, + address _to, + uint256 _reservedUntilTokenId, + uint256 _tokenId + ) public { + vm.assume(_minter != founder && _minter != address(0) && _minter != address(auction)); + vm.assume(_to != founder && _to != address(0) && _to != address(auction) && _to != _minter); + vm.assume(_tokenId < _reservedUntilTokenId); + deployAltMock(_reservedUntilTokenId); + + TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](1); + TokenTypesV2.MinterParams memory p1 = TokenTypesV2.MinterParams({ minter: _minter, allowed: true }); + minters[0] = p1; + + vm.prank(address(founder)); + token.updateMinters(minters); + + vm.prank(minters[0].minter); + token.mintFromReserveTo(minters[0].minter, _tokenId); + assertEq(token.ownerOf(_tokenId), minters[0].minter); + + vm.prank(minters[0].minter); + token.transferFrom(minters[0].minter, _to, _tokenId); + assertEq(token.ownerOf(_tokenId), _to); + } + + function testRevert_CannotTransferOnceLocked( + address _minter, + address _to, + uint256 _reservedUntilTokenId, + uint256 _tokenId + ) public { + vm.assume(_minter != founder && _minter != address(0) && _minter != address(auction)); + vm.assume(_to != founder && _to != address(0) && _to != address(auction) && _to != _minter); + vm.assume(_tokenId < _reservedUntilTokenId); + deployAltMock(_reservedUntilTokenId); + + TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](1); + TokenTypesV2.MinterParams memory p1 = TokenTypesV2.MinterParams({ minter: _minter, allowed: true }); + minters[0] = p1; + + vm.prank(address(founder)); + token.updateMinters(minters); + + vm.prank(minters[0].minter); + token.mintFromReserveTo(minters[0].minter, _tokenId); + assertEq(token.ownerOf(_tokenId), minters[0].minter); + + vm.prank(minters[0].minter); + soulboundToken.transferFromAndLock(minters[0].minter, _to, _tokenId); + assertEq(token.ownerOf(_tokenId), _to); + + vm.expectRevert(abi.encodeWithSignature("TOKEN_LOCKED()")); + vm.prank(_to); + token.transferFrom(_to, minters[0].minter, _tokenId); + } + + function testRevert_CannotTransferAndLockNonReservedToken(address _to, uint256 _reservedUntilTokenId) public { + vm.assume(_to != founder && _to != address(0) && _to != address(auction)); + deployAltMock(_reservedUntilTokenId); + + vm.startPrank(address(auction)); + uint256 tokenId = token.mint(); + vm.expectRevert(abi.encodeWithSignature("TOKEN_NOT_LOCKABLE()")); + soulboundToken.transferFromAndLock(address(auction), _to, tokenId); + vm.stopPrank(); + } +} diff --git a/test/Token.t.sol b/test/Token.t.sol index 37dd613..5a09f88 100644 --- a/test/Token.t.sol +++ b/test/Token.t.sol @@ -869,7 +869,7 @@ contract TokenTest is NounsBuilderTest, TokenTypesV1 { function test_SingleMintCannotMintReserves(address _minter, uint256 _reservedUntilTokenId) public { vm.assume(_minter != founder && _minter != address(0) && _minter != address(auction)); - vm.assume(_reservedUntilTokenId > 0 && _reservedUntilTokenId < 100000); + vm.assume(_reservedUntilTokenId > 0 && _reservedUntilTokenId < 4000); deployAltMock(_reservedUntilTokenId); TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](1); @@ -896,7 +896,7 @@ contract TokenTest is NounsBuilderTest, TokenTypesV1 { uint256 _amount ) public { vm.assume(_minter != founder && _minter != address(0) && _minter != address(auction)); - vm.assume(_reservedUntilTokenId > 0 && _reservedUntilTokenId < 100000 && _amount > 0 && _amount < 20); + vm.assume(_reservedUntilTokenId > 0 && _reservedUntilTokenId < 4000 && _amount > 0 && _amount < 20); deployAltMock(_reservedUntilTokenId); TokenTypesV2.MinterParams[] memory minters = new TokenTypesV2.MinterParams[](1); From 517841dc9c091b4897c3a6041b46170574df6d89 Mon Sep 17 00:00:00 2001 From: neokry Date: Fri, 18 Aug 2023 17:05:50 +0900 Subject: [PATCH 4/4] Fix token names --- .storage-layout | 54 ++++++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/.storage-layout b/.storage-layout index f2258bc..60d4f71 100644 --- a/.storage-layout +++ b/.storage-layout @@ -28,7 +28,7 @@ | _status | uint256 | 2 | 0 | 32 | src/auction/Auction.sol:Auction | | _paused | bool | 3 | 0 | 1 | src/auction/Auction.sol:Auction | | settings | struct AuctionTypesV1.Settings | 4 | 0 | 64 | src/auction/Auction.sol:Auction | -| token | contract Token | 6 | 0 | 20 | src/auction/Auction.sol:Auction | +| token | contract IBaseToken | 6 | 0 | 20 | src/auction/Auction.sol:Auction | | auction | struct AuctionTypesV1.Auction | 7 | 0 | 96 | src/auction/Auction.sol:Auction | ======================= @@ -67,29 +67,29 @@ ➡ Token ======================= -| Name | Type | Slot | Offset | Bytes | Contract | -|--------------------------|------------------------------------------------------------------------|------|--------|-------|---------------------------| -| _initialized | uint8 | 0 | 0 | 1 | src/token/Token.sol:Token | -| _initializing | bool | 0 | 1 | 1 | src/token/Token.sol:Token | -| _owner | address | 0 | 2 | 20 | src/token/Token.sol:Token | -| _pendingOwner | address | 1 | 0 | 20 | src/token/Token.sol:Token | -| _status | uint256 | 2 | 0 | 32 | src/token/Token.sol:Token | -| HASHED_NAME | bytes32 | 3 | 0 | 32 | src/token/Token.sol:Token | -| HASHED_VERSION | bytes32 | 4 | 0 | 32 | src/token/Token.sol:Token | -| INITIAL_DOMAIN_SEPARATOR | bytes32 | 5 | 0 | 32 | src/token/Token.sol:Token | -| INITIAL_CHAIN_ID | uint256 | 6 | 0 | 32 | src/token/Token.sol:Token | -| nonces | mapping(address => uint256) | 7 | 0 | 32 | src/token/Token.sol:Token | -| name | string | 8 | 0 | 32 | src/token/Token.sol:Token | -| symbol | string | 9 | 0 | 32 | src/token/Token.sol:Token | -| owners | mapping(uint256 => address) | 10 | 0 | 32 | src/token/Token.sol:Token | -| balances | mapping(address => uint256) | 11 | 0 | 32 | src/token/Token.sol:Token | -| tokenApprovals | mapping(uint256 => address) | 12 | 0 | 32 | src/token/Token.sol:Token | -| operatorApprovals | mapping(address => mapping(address => bool)) | 13 | 0 | 32 | src/token/Token.sol:Token | -| delegation | mapping(address => address) | 14 | 0 | 32 | src/token/Token.sol:Token | -| numCheckpoints | mapping(address => uint256) | 15 | 0 | 32 | src/token/Token.sol:Token | -| checkpoints | mapping(address => mapping(uint256 => struct IERC721Votes.Checkpoint)) | 16 | 0 | 32 | src/token/Token.sol:Token | -| settings | struct TokenTypesV1.Settings | 17 | 0 | 64 | src/token/Token.sol:Token | -| founder | mapping(uint256 => struct TokenTypesV1.Founder) | 19 | 0 | 32 | src/token/Token.sol:Token | -| tokenRecipient | mapping(uint256 => struct TokenTypesV1.Founder) | 20 | 0 | 32 | src/token/Token.sol:Token | -| minter | mapping(address => bool) | 21 | 0 | 32 | src/token/Token.sol:Token | -| reservedUntilTokenId | uint256 | 22 | 0 | 32 | src/token/Token.sol:Token | +| Name | Type | Slot | Offset | Bytes | Contract | +|--------------------------|------------------------------------------------------------------------|------|--------|-------|-----------------------------------| +| _initialized | uint8 | 0 | 0 | 1 | src/token/default/Token.sol:Token | +| _initializing | bool | 0 | 1 | 1 | src/token/default/Token.sol:Token | +| _owner | address | 0 | 2 | 20 | src/token/default/Token.sol:Token | +| _pendingOwner | address | 1 | 0 | 20 | src/token/default/Token.sol:Token | +| _status | uint256 | 2 | 0 | 32 | src/token/default/Token.sol:Token | +| HASHED_NAME | bytes32 | 3 | 0 | 32 | src/token/default/Token.sol:Token | +| HASHED_VERSION | bytes32 | 4 | 0 | 32 | src/token/default/Token.sol:Token | +| INITIAL_DOMAIN_SEPARATOR | bytes32 | 5 | 0 | 32 | src/token/default/Token.sol:Token | +| INITIAL_CHAIN_ID | uint256 | 6 | 0 | 32 | src/token/default/Token.sol:Token | +| nonces | mapping(address => uint256) | 7 | 0 | 32 | src/token/default/Token.sol:Token | +| name | string | 8 | 0 | 32 | src/token/default/Token.sol:Token | +| symbol | string | 9 | 0 | 32 | src/token/default/Token.sol:Token | +| owners | mapping(uint256 => address) | 10 | 0 | 32 | src/token/default/Token.sol:Token | +| balances | mapping(address => uint256) | 11 | 0 | 32 | src/token/default/Token.sol:Token | +| tokenApprovals | mapping(uint256 => address) | 12 | 0 | 32 | src/token/default/Token.sol:Token | +| operatorApprovals | mapping(address => mapping(address => bool)) | 13 | 0 | 32 | src/token/default/Token.sol:Token | +| delegation | mapping(address => address) | 14 | 0 | 32 | src/token/default/Token.sol:Token | +| numCheckpoints | mapping(address => uint256) | 15 | 0 | 32 | src/token/default/Token.sol:Token | +| checkpoints | mapping(address => mapping(uint256 => struct IERC721Votes.Checkpoint)) | 16 | 0 | 32 | src/token/default/Token.sol:Token | +| settings | struct TokenTypesV1.Settings | 17 | 0 | 64 | src/token/default/Token.sol:Token | +| founder | mapping(uint256 => struct TokenTypesV1.Founder) | 19 | 0 | 32 | src/token/default/Token.sol:Token | +| tokenRecipient | mapping(uint256 => struct TokenTypesV1.Founder) | 20 | 0 | 32 | src/token/default/Token.sol:Token | +| minter | mapping(address => bool) | 21 | 0 | 32 | src/token/default/Token.sol:Token | +| reservedUntilTokenId | uint256 | 22 | 0 | 32 | src/token/default/Token.sol:Token |