From 88d076c8088bd1d1301921151235b6c00f35b5de Mon Sep 17 00:00:00 2001 From: Sabnock <24715302+Sabnock01@users.noreply.github.com> Date: Fri, 17 Nov 2023 02:10:24 -0600 Subject: [PATCH] :sparkles: feat: TeamRegistry (#29) * feat: TeamRegistry * v2 * remove ITeamRegistry * add TeamRegistry tests * v3 * add request logic * docs: natspec * fix: createTeam tests and update requestMembership * chore: update requestJoin w/ new event * tests: add more unit tests and dividers * feat: implemenet discussed interface * refactor: move kickMember and setApprovalForMember contents to internal fns * feat: add batch fns * refactor: kick -> remove; sounds slightly nicer lmao * docs: some @dev specifications * fix: _newLeader -> _member * tests: fix ci * tests: add transferTeamLeadership unit tests * chore: remove unnecessary _from param from transferTeam * tests: add unit tests for rest of the contract * chore: run forge fmt --------- Co-authored-by: e6f4e37l <66016924+fiveoutofnine@users.noreply.github.com> --- .gas-snapshot | 154 +++++++----- lib/forge-std | 2 +- src/TeamRegistry.sol | 278 +++++++++++++++++++++ test/TeamRegistry.t.sol | 537 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 902 insertions(+), 69 deletions(-) create mode 100644 src/TeamRegistry.sol create mode 100644 test/TeamRegistry.t.sol diff --git a/.gas-snapshot b/.gas-snapshot index 6e8e4e3..6685e8f 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,77 +1,95 @@ AuthorshipTokenTest:test_curtaMint() (gas: 85648) -AuthorshipTokenTest:test_curtaMint_SenderIsNotCurta_RevertsUnauthorized(address) (runs: 256, μ: 10204, ~: 10204) -AuthorshipTokenTest:test_ownerMint_FuzzMintTimestamps_IssuesTokensCorrectly(uint256) (runs: 256, μ: 6430685, ~: 5928022) -AuthorshipTokenTest:test_ownerMint_SenderIsNotOwner_RevertUnauthorized(address) (runs: 256, μ: 12935, ~: 12935) +AuthorshipTokenTest:test_curtaMint_SenderIsNotCurta_RevertsUnauthorized(address) (runs: 256, μ: 10248, ~: 10248) +AuthorshipTokenTest:test_ownerMint_FuzzMintTimestamps_IssuesTokensCorrectly(uint256) (runs: 256, μ: 6412423, ~: 5915354) +AuthorshipTokenTest:test_ownerMint_SenderIsNotOwner_RevertUnauthorized(address) (runs: 256, μ: 12979, ~: 12979) AuthorshipTokenTest:test_ownerMint_SenderIsOwner_AllowsMint() (gas: 108202) -AuthorshipTokenTest:test_tokenURI_MintedToken_Succeeds() (gas: 232) -AuthorshipTokenTest:test_tokenURI_UnmintedToken_Fails() (gas: 12629) -CurtaTest:test_Initialization_DeployAddressesMatch() (gas: 11297) -CurtaTest:test_addPuzzle() (gas: 305332) -CurtaTest:test_addPuzzle_UseAuthorshipToken_UpdatesStorage() (gas: 298535) -CurtaTest:test_addPuzzle_UseSameAuthorshipTokenTwice_Fails() (gas: 299355) -CurtaTest:test_addPuzzle_UseUnownedAuthorshipToken_RevertsUnauthorized() (gas: 199252) -CurtaTest:test_approve() (gas: 434876) -CurtaTest:test_approve_SenderIsNotOwner_RevertsUnauthorized() (gas: 410380) -CurtaTest:test_approve_WithApprovalForAllTrue_AllowsTransfer() (gas: 457096) -CurtaTest:test_balanceOf_ZeroAddress_Fails() (gas: 8674) -CurtaTest:test_setApprovalForAll_False_UpdatesStorage() (gas: 15966) -CurtaTest:test_setApprovalForAll_True_UpdatesStorage() (gas: 35841) +AuthorshipTokenTest:test_tokenURI_MintedToken_Succeeds() (gas: 254) +AuthorshipTokenTest:test_tokenURI_UnmintedToken_Fails() (gas: 12652) +CurtaTest:test_Initialization_DeployAddressesMatch() (gas: 11275) +CurtaTest:test_addPuzzle() (gas: 305309) +CurtaTest:test_addPuzzle_UseAuthorshipToken_UpdatesStorage() (gas: 298512) +CurtaTest:test_addPuzzle_UseSameAuthorshipTokenTwice_Fails() (gas: 299421) +CurtaTest:test_addPuzzle_UseUnownedAuthorshipToken_RevertsUnauthorized() (gas: 199296) +CurtaTest:test_approve() (gas: 434878) +CurtaTest:test_approve_SenderIsNotOwner_RevertsUnauthorized() (gas: 410379) +CurtaTest:test_approve_WithApprovalForAllTrue_AllowsTransfer() (gas: 457095) +CurtaTest:test_balanceOf_ZeroAddress_Fails() (gas: 8718) +CurtaTest:test_setApprovalForAll_False_UpdatesStorage() (gas: 15965) +CurtaTest:test_setApprovalForAll_True_UpdatesStorage() (gas: 35840) CurtaTest:test_setFermat_AsRandomAccount_Succeeds(address) (runs: 256, μ: 455137, ~: 455137) -CurtaTest:test_setFermat_InitialSet_UpdatesStorage() (gas: 511807) -CurtaTest:test_setFermat_SetAfterTransfer_Succeeds(address) (runs: 256, μ: 876595, ~: 876595) -CurtaTest:test_setFermat_SetDifferentPuzzlesTwiceInIncreasingOrder_Succeeds() (gas: 870154) -CurtaTest:test_setFermat_SetNonFermatPuzzle_Fails() (gas: 780207) -CurtaTest:test_setFermat_SetSamePuzzleTwice_Fails() (gas: 456913) -CurtaTest:test_setFermat_SetUnsolvedPuzzle_Fails() (gas: 300144) -CurtaTest:test_setPuzzleColors() (gas: 306229) -CurtaTest:test_setPuzzleColors_SetUnauthoredPuzzle_RevertsUnauthorized() (gas: 299507) -CurtaTest:test_solve() (gas: 657540) -CurtaTest:test_solve_DuringAllPhases_FirstSolveTimestampOnlySetOnFirstBlood(uint40) (runs: 256, μ: 520949, ~: 520275) -CurtaTest:test_solve_DuringPhase1WithPayment_PaysAuthor(uint256) (runs: 256, μ: 495665, ~: 496974) -CurtaTest:test_solve_DuringPhase2WithPayment_PaysAuthor(uint256) (runs: 256, μ: 533897, ~: 533897) -CurtaTest:test_solve_DuringPhase2_RequiresETH(uint256) (runs: 256, μ: 489577, ~: 487644) -CurtaTest:test_solve_DuringPhase3_Fails(uint40) (runs: 256, μ: 434098, ~: 434098) +CurtaTest:test_setFermat_InitialSet_UpdatesStorage() (gas: 511851) +CurtaTest:test_setFermat_SetAfterTransfer_Succeeds(address) (runs: 256, μ: 876639, ~: 876639) +CurtaTest:test_setFermat_SetDifferentPuzzlesTwiceInIncreasingOrder_Succeeds() (gas: 870131) +CurtaTest:test_setFermat_SetNonFermatPuzzle_Fails() (gas: 780187) +CurtaTest:test_setFermat_SetSamePuzzleTwice_Fails() (gas: 456892) +CurtaTest:test_setFermat_SetUnsolvedPuzzle_Fails() (gas: 300099) +CurtaTest:test_setPuzzleColors() (gas: 306206) +CurtaTest:test_setPuzzleColors_SetUnauthoredPuzzle_RevertsUnauthorized() (gas: 299486) +CurtaTest:test_solve() (gas: 657518) +CurtaTest:test_solve_DuringAllPhases_FirstSolveTimestampOnlySetOnFirstBlood(uint40) (runs: 256, μ: 521015, ~: 520341) +CurtaTest:test_solve_DuringPhase1WithPayment_PaysAuthor(uint256) (runs: 256, μ: 495643, ~: 496952) +CurtaTest:test_solve_DuringPhase2WithPayment_PaysAuthor(uint256) (runs: 256, μ: 533875, ~: 533875) +CurtaTest:test_solve_DuringPhase2_RequiresETH(uint256) (runs: 256, μ: 489257, ~: 487666) +CurtaTest:test_solve_DuringPhase3_Fails(uint40) (runs: 256, μ: 434097, ~: 434097) CurtaTest:test_solve_FirstBlood_AuthorshipTokenMintPotentialRevertBranch() (gas: 311249) -CurtaTest:test_solve_FirstBlood_MintsAuthorshipToken() (gas: 413358) -CurtaTest:test_solve_FirstBlood_UpdatesFirstSolveTimestamp(uint40) (runs: 256, μ: 407340, ~: 407340) -CurtaTest:test_solve_IncorrectSolution_Fails(uint256) (runs: 256, μ: 309250, ~: 309250) +CurtaTest:test_solve_FirstBlood_MintsAuthorshipToken() (gas: 413335) +CurtaTest:test_solve_FirstBlood_UpdatesFirstSolveTimestamp(uint40) (runs: 256, μ: 407384, ~: 407384) +CurtaTest:test_solve_IncorrectSolution_Fails(uint256) (runs: 256, μ: 309228, ~: 309228) CurtaTest:test_solve_NonExistantPuzzle_Fails() (gas: 13650) -CurtaTest:test_solve_SamePuzzleTwice_Fails() (gas: 407397) -CurtaTest:test_solve_Success_MintsFlag() (gas: 409078) -CurtaTest:test_solve_Success_UpdatesSolveCounters() (gas: 613748) -CurtaTest:test_solve_Success_UpdatesStorage() (gas: 408000) -CurtaTest:test_supportsInterface() (gas: 8058) -CurtaTest:test_tokenURI_MintedToken_Succeeds() (gas: 234) -CurtaTest:test_tokenURI_UnmintedToken_Fails() (gas: 12952) -CurtaTest:test_transferFrom() (gas: 450609) -CurtaTest:test_transferFrom_SenderIsOwner_AllowsTransfer() (gas: 434582) -CurtaTest:test_transferFrom_ToZeroAddress_Fails() (gas: 407936) -CurtaTest:test_transferFrom_Unauthorized_RevertsUnauthorized() (gas: 412838) -CurtaTest:test_transferFrom_WithApprovalForAllTrue_AllowsTransfer() (gas: 460343) -CurtaTest:test_transferFrom_WithTokenApproval_AllowsTransfer() (gas: 440767) -CurtaTest:test_transferFrom_WrongFrom_Fails() (gas: 407858) -DeployBaseGoerliTest:test_AddressInitializationCorrectness() (gas: 23494) -DeployBaseGoerliTest:test_authorshipTokenAuthorsEquality() (gas: 13651) -DeployBaseGoerliTest:test_authorshipTokenIssueLengthEquality() (gas: 11460) -DeployBaseGoerliTest:test_authorshipTokenMinting() (gas: 108701) +CurtaTest:test_solve_SamePuzzleTwice_Fails() (gas: 407374) +CurtaTest:test_solve_Success_MintsFlag() (gas: 409057) +CurtaTest:test_solve_Success_UpdatesSolveCounters() (gas: 613814) +CurtaTest:test_solve_Success_UpdatesStorage() (gas: 408044) +CurtaTest:test_supportsInterface() (gas: 8102) +CurtaTest:test_tokenURI_MintedToken_Succeeds() (gas: 278) +CurtaTest:test_tokenURI_UnmintedToken_Fails() (gas: 12908) +CurtaTest:test_transferFrom() (gas: 450588) +CurtaTest:test_transferFrom_SenderIsOwner_AllowsTransfer() (gas: 434581) +CurtaTest:test_transferFrom_ToZeroAddress_Fails() (gas: 407958) +CurtaTest:test_transferFrom_Unauthorized_RevertsUnauthorized() (gas: 412860) +CurtaTest:test_transferFrom_WithApprovalForAllTrue_AllowsTransfer() (gas: 460342) +CurtaTest:test_transferFrom_WithTokenApproval_AllowsTransfer() (gas: 440811) +CurtaTest:test_transferFrom_WrongFrom_Fails() (gas: 407902) +DeployBaseGoerliTest:test_AddressInitializationCorrectness() (gas: 23561) +DeployBaseGoerliTest:test_authorshipTokenAuthorsEquality() (gas: 13695) +DeployBaseGoerliTest:test_authorshipTokenIssueLengthEquality() (gas: 11482) +DeployBaseGoerliTest:test_authorshipTokenMinting() (gas: 108745) DeployBaseGoerliTest:test_authorshipTokenOwnerEquality() (gas: 13799) -DeployBaseGoerliTest:test_curtaOwnerEquality() (gas: 13832) -DeployBaseMainnetTest:test_AddressInitializationCorrectness() (gas: 23494) -DeployBaseMainnetTest:test_authorshipTokenAuthorsEquality() (gas: 27900) -DeployBaseMainnetTest:test_authorshipTokenIssueLengthEquality() (gas: 11460) -DeployBaseMainnetTest:test_authorshipTokenMinting() (gas: 91601) +DeployBaseGoerliTest:test_curtaOwnerEquality() (gas: 13854) +DeployBaseMainnetTest:test_AddressInitializationCorrectness() (gas: 23561) +DeployBaseMainnetTest:test_authorshipTokenAuthorsEquality() (gas: 27944) +DeployBaseMainnetTest:test_authorshipTokenIssueLengthEquality() (gas: 11482) +DeployBaseMainnetTest:test_authorshipTokenMinting() (gas: 91645) DeployBaseMainnetTest:test_authorshipTokenOwnerEquality() (gas: 13799) -DeployBaseMainnetTest:test_curtaOwnerEquality() (gas: 13832) -DeployGoerliTest:test_AddressInitializationCorrectness() (gas: 23494) -DeployGoerliTest:test_authorshipTokenAuthorsEquality() (gas: 13651) -DeployGoerliTest:test_authorshipTokenIssueLengthEquality() (gas: 11460) -DeployGoerliTest:test_authorshipTokenMinting() (gas: 108701) +DeployBaseMainnetTest:test_curtaOwnerEquality() (gas: 13854) +DeployGoerliTest:test_AddressInitializationCorrectness() (gas: 23561) +DeployGoerliTest:test_authorshipTokenAuthorsEquality() (gas: 13695) +DeployGoerliTest:test_authorshipTokenIssueLengthEquality() (gas: 11482) +DeployGoerliTest:test_authorshipTokenMinting() (gas: 108745) DeployGoerliTest:test_authorshipTokenOwnerEquality() (gas: 13799) -DeployGoerliTest:test_curtaOwnerEquality() (gas: 13832) -DeployMainnetTest:test_AddressInitializationCorrectness() (gas: 23494) -DeployMainnetTest:test_authorshipTokenAuthorsEquality() (gas: 227362) -DeployMainnetTest:test_authorshipTokenIssueLengthEquality() (gas: 11460) -DeployMainnetTest:test_authorshipTokenMinting() (gas: 91601) +DeployGoerliTest:test_curtaOwnerEquality() (gas: 13854) +DeployMainnetTest:test_AddressInitializationCorrectness() (gas: 23561) +DeployMainnetTest:test_authorshipTokenAuthorsEquality() (gas: 227406) +DeployMainnetTest:test_authorshipTokenIssueLengthEquality() (gas: 11482) +DeployMainnetTest:test_authorshipTokenMinting() (gas: 91645) DeployMainnetTest:test_authorshipTokenOwnerEquality() (gas: 13799) -DeployMainnetTest:test_curtaOwnerEquality() (gas: 13832) -OptimizationsTest:testFuzzComputePhaseFromTimestampBranchlessOptimization(uint40,uint40) (runs: 256, μ: 3545, ~: 3531) \ No newline at end of file +DeployMainnetTest:test_curtaOwnerEquality() (gas: 13854) +OptimizationsTest:testFuzzComputePhaseFromTimestampBranchlessOptimization(uint40,uint40) (runs: 256, μ: 3602, ~: 3575) +TeamRegistyTest:test_batchRemoveMember() (gas: 214233) +TeamRegistyTest:test_batchRemoveMember_NotTeamLeader_Fails() (gas: 203684) +TeamRegistyTest:test_batchRemoveMember_NotTeamMember_Fails() (gas: 172041) +TeamRegistyTest:test_batchSetApprovalForMember() (gas: 226223) +TeamRegistyTest:test_batchSetApprovalForMember_NotTeamLeader_Fails() (gas: 203751) +TeamRegistyTest:test_createTeam(address[]) (runs: 256, μ: 3891165, ~: 3862258) +TeamRegistyTest:test_createTeam_TeamLeaderCreatesNewTeam_Fails() (gas: 175521) +TeamRegistyTest:test_removeMember() (gas: 181050) +TeamRegistyTest:test_removeMember_NotTeamLeader_Fails() (gas: 200049) +TeamRegistyTest:test_removeMember_NotTeamMember_Fails() (gas: 176628) +TeamRegistyTest:test_setApprovalForMember() (gas: 200305) +TeamRegistyTest:test_setApprovalForMember_NotTeamLeader_Fails() (gas: 200144) +TeamRegistyTest:test_transferTeam() (gas: 204160) +TeamRegistyTest:test_transferTeamLeadership() (gas: 219736) +TeamRegistyTest:test_transferTeamLeadership_NotTeamLeader_Fails() (gas: 200050) +TeamRegistyTest:test_transferTeamLeadership_NotTeamMember_Fails() (gas: 176599) +TeamRegistyTest:test_transferTeam_ApprovalFalse_Fails() (gas: 174483) +TeamRegistyTest:test_transferTeam_IsTeamLeader_Fails() (gas: 170816) \ No newline at end of file diff --git a/lib/forge-std b/lib/forge-std index 058d200..bdea49f 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 058d2004ac10cc8f194625fb107fb7a87c4e702d +Subproject commit bdea49f9bb3c58c8c35850c3bdc17eaeea756e9a diff --git a/src/TeamRegistry.sol b/src/TeamRegistry.sol new file mode 100644 index 0000000..e28623a --- /dev/null +++ b/src/TeamRegistry.sol @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +/// @title TeamRegistry +/// @author Sabnock01 +/// @notice A registry of teams for Curta Puzzles. +contract TeamRegistry { + // ------------------------------------------------------------------------- + // Errors + // ------------------------------------------------------------------------- + + /// @notice Emitted when `msg.sender` is the team leader. + /// @param _id The ID of the team. + error IsTeamLeader(uint256 _id); + + /// @notice Emitted when some address is not part of the team. + /// @param _id The ID of the team. + /// @param _member The address of the member. + error NotInTeam(uint256 _id, address _member); + + /// @notice Emitted when `msg.sender` is not the team leader. + /// @param _id The ID of the team. + error NotTeamLeader(uint256 _id); + + /// @notice Emitted when `msg.sender` is unauthorized to join a team. + error Unauthorized(); + + // ------------------------------------------------------------------------- + // Structs + // ------------------------------------------------------------------------- + + /// @notice A struct containing information about an address's team + /// membership. + /// @dev If `id` is 0, the user is not part of any team. + /// @param id The ID of the team they are a part of. + /// @param isLeader Whether or not they are the leader of the team. + struct Team { + uint248 id; + bool isLeader; + } + + // ------------------------------------------------------------------------- + // Events + // ------------------------------------------------------------------------- + + /// @notice Emitted when a new team is created. + /// @param _id The ID of the team. + /// @param _leader The address of the leader of the team. + event CreateTeam(uint256 indexed _id, address indexed _leader); + + /// @notice Emitted when a leader approves a member to join a team. + /// @param _id The ID of the team. + /// @param _member The address of the member. + /// @param _approved Whether `_member` is approved to join the team. + event SetApprovalForMember( + uint256 indexed _id, address indexed _member, bool indexed _approved + ); + + /// @notice Emitted when a member transfers to another team. + /// @dev Team ID of 0 denotes that the member is not part of a team (i.e. + /// they are participating individually). + /// @param _from The team ID of the team the member is transferring from. + /// @param _to The team ID of the team the member is transferring to. + /// @param _member The address of the member. + event TransferTeam(uint256 indexed _from, uint256 indexed _to, address indexed _member); + + /// @notice Emitted when team leadership is transferred. + /// @param _id The ID of the team. + /// @param _from The address of the old leader of the team. + /// @param _to The address of the new leader of the team. + event TransferTeamLeadership(uint256 indexed _id, address indexed _from, address indexed _to); + + // ------------------------------------------------------------------------- + // Storage + // ------------------------------------------------------------------------- + + /// @notice The total number of teams. + uint256 internal teamId; + + /// @notice Mapping for whether a member is approved to join a team. + mapping(uint256 => mapping(address => bool)) public getApproved; + + /// @notice Mapping of team member addresses to the team they are a part of. + mapping(address => Team) public getTeam; + + // ------------------------------------------------------------------------- + // Functions + // ------------------------------------------------------------------------- + + /// @notice Batch remove members from a team. + /// @dev Since an address may only be part of 1 team at a time, the function + /// automatically retrieves the team ID to remove from and reverts if + /// `msg.sender` is not the leader. + /// @param _members A list of addresses to remove from the team. + function batchRemoveMember(address[] calldata _members) external { + Team memory team = getTeam[msg.sender]; + + // Revert if `msg.sender` is not the leader of the team. + if (!team.isLeader) revert NotTeamLeader(team.id); + + // Go through the list and remove members. + uint256 length = _members.length; + for (uint256 i; i < length;) { + _removeMemberFromTeam(team.id, _members[i]); + + unchecked { + ++i; + } + } + } + + /// @notice Batch set approvals for members to join a team. + /// @dev Since an address may only be part of 1 team at a time, the function + /// automatically retrieves the team ID to set approvals for and reverts if + /// `msg.sender` is not the leader. + /// @param _members A list of addresses to set approvals for. + /// @param _approved Whether or not the members are approved to join the + /// team. + function batchSetApprovalForMember(address[] calldata _members, bool _approved) external { + Team memory team = getTeam[msg.sender]; + + // Revert if `msg.sender` is not the leader of the team. + if (!team.isLeader) revert NotTeamLeader(team.id); + + // Go through the list and set approval. + uint256 length = _members.length; + for (uint256 i; i < length;) { + _setApprovalForMember(team.id, _members[i], _approved); + + unchecked { + ++i; + } + } + } + + /// @notice Create a team with invitations sent out to a list of members. + /// @dev The function reverts if `msg.sender` is the leader of another team. + /// @param _members A list of addresses to invite. + /// @return newTeamId The ID of the created team. + function createTeam(address[] calldata _members) external returns (uint256 newTeamId) { + unchecked { + newTeamId = ++teamId; + } + + Team memory team = getTeam[msg.sender]; + // Revert if `msg.sender` is already the leader of a team. + if (team.isLeader) revert IsTeamLeader(team.id); + + // Mark `msg.sender` as approved to join the team. + getApproved[newTeamId][msg.sender] = true; + // Mark new team as `msg.sender`'s team and as leader. + getTeam[msg.sender] = Team({ id: uint248(newTeamId), isLeader: true }); + + // Approve all members to join the team. + uint256 length = _members.length; + for (uint256 i; i < length;) { + getApproved[newTeamId][_members[i]] = true; + + // Emit event. + emit SetApprovalForMember(newTeamId, _members[i], true); + + unchecked { + ++i; + } + } + + // Emit events. + emit CreateTeam(newTeamId, msg.sender); + emit SetApprovalForMember(newTeamId, msg.sender, true); + emit TransferTeam(team.id, newTeamId, msg.sender); + } + + /// @notice Remove a member from a team. + /// @dev Since an address may only be part of 1 team at a time, the function + /// automatically retrieves the team ID to remove from and reverts if + /// `msg.sender` is not the leader. + /// @param _member The address of the member to remove from the team. + function removeMember(address _member) external { + Team memory team = getTeam[msg.sender]; + + // Revert if `msg.sender` is not the leader of the team. + if (!team.isLeader) revert NotTeamLeader(team.id); + + _removeMemberFromTeam(team.id, _member); + } + + /// @notice Set approval for a member to join a team. + /// @dev Since an address may only be part of 1 team at a time, the function + /// automatically retrieves the team ID to approve for and reverts if + /// `msg.sender` is not the leader. + /// @param _member The address of the member to set approval for. + /// @param _approved Whether or not the member is approved to join the team. + function setApprovalForMember(address _member, bool _approved) external { + Team memory team = getTeam[msg.sender]; + + // Revert if `msg.sender` is not the leader of the team. + if (!team.isLeader) revert NotTeamLeader(team.id); + + _setApprovalForMember(team.id, _member, _approved); + } + + /// @notice Transfer team to `_to`. + /// @dev Reverts if `msg.sender` is the leader of a team or they are not + /// approved to join team `_to`. + /// @param _to The ID of the team to transfer to. + function transferTeam(uint256 _to) external { + Team memory team = getTeam[msg.sender]; + + // Revert if `msg.sender` is the leader of the team. + if (team.isLeader) revert IsTeamLeader(team.id); + + // Revert if `msg.sender` is not approved to join the team. + if (!getApproved[_to][msg.sender]) revert Unauthorized(); + + // Transfer team. + getTeam[msg.sender].id = uint248(_to); + + // Emit event. + emit TransferTeam(team.id, _to, msg.sender); + } + + /// @notice Transfer team leadership to another member. + /// @dev Since an address may only be part of 1 team at a time, the function + /// automatically retrieves the team ID to transfer leadership for and + /// reverts if `msg.sender` is not the leader or if `_member` is not part + /// of the team. + /// @param _member The address of the new leader. + function transferTeamLeadership(address _member) external { + Team memory team = getTeam[msg.sender]; + + // Revert if `msg.sender` is not the leader of the team. + if (!team.isLeader) revert NotTeamLeader(team.id); + + // Revert if `_member` is not part of the team. + if (getTeam[_member].id != team.id) revert NotInTeam(team.id, _member); + + // Transfer leadership. + getTeam[msg.sender].isLeader = false; + getTeam[_member].isLeader = true; + + // Emit event. + emit TransferTeamLeadership(team.id, msg.sender, _member); + } + + // ------------------------------------------------------------------------- + // Helper functions + // ------------------------------------------------------------------------- + + /// @notice Removes a member from a team and emits corresponding events. + /// @param _teamId The ID of the team. + /// @param _member The address of the member to remove. + function _removeMemberFromTeam(uint256 _teamId, address _member) internal { + // Revert if `_member` is not part of the team. + if (getTeam[_member].id != _teamId) revert NotInTeam(_teamId, _member); + + // Mark removed member as unapproved to join, and remove them from the + // team. + getApproved[_teamId][_member] = false; + getTeam[_member].id = 0; + + // Emit events. + emit SetApprovalForMember(_teamId, _member, false); + emit TransferTeam(_teamId, 0, _member); + } + + /// @notice Sets approval for a member to join a team and emits + /// corresponding events. + /// @param _teamId The ID of the team. + /// @param _member The address of the member to set approval for. + /// @param _approved Whether or not the member is approved to join the team. + function _setApprovalForMember(uint256 _teamId, address _member, bool _approved) internal { + // Set approval. + getApproved[_teamId][_member] = _approved; + + // Emit event. + emit SetApprovalForMember(_teamId, _member, _approved); + } +} diff --git a/test/TeamRegistry.t.sol b/test/TeamRegistry.t.sol new file mode 100644 index 0000000..5e036ba --- /dev/null +++ b/test/TeamRegistry.t.sol @@ -0,0 +1,537 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import { Test, console } from "forge-std/Test.sol"; + +import { TeamRegistry } from "@/contracts/TeamRegistry.sol"; + +/// @notice Unit tests for {TeamRegistry}, organized by functions. +contract TeamRegistyTest is Test { + // ------------------------------------------------------------------------- + // Events + // ------------------------------------------------------------------------- + + /// @notice Emitted when a new team is created. + /// @param _id The ID of the team. + /// @param _leader The address of the leader of the team. + event CreateTeam(uint256 indexed _id, address indexed _leader); + + /// @notice Emitted when a leader approves a member to join a team. + /// @param _id The ID of the team. + /// @param _member The address of the member. + /// @param _approved Whether `_member` is approved to join the team. + event SetApprovalForMember( + uint256 indexed _id, address indexed _member, bool indexed _approved + ); + + /// @notice Emitted when a member transfers to another team. + /// @dev Team ID of 0 denotes that the member is not part of a team (i.e. + /// they are participating individually). + /// @param _from The team ID of the team the member is transferring from. + /// @param _to The team ID of the team the member is transferring to. + /// @param _member The address of the member. + event TransferTeam(uint256 indexed _from, uint256 indexed _to, address indexed _member); + + /// @notice Emitted when team leadership is transferred. + /// @param _id The ID of the team. + /// @param _from The address of the old leader of the team. + /// @param _to The address of the new leader of the team. + event TransferTeamLeadership(uint256 indexed _id, address indexed _from, address indexed _to); + + // ------------------------------------------------------------------------- + // Contracts + // ------------------------------------------------------------------------- + + /// @notice The team registry contract. + TeamRegistry public tr; + + // ------------------------------------------------------------------------- + // Set up + // ------------------------------------------------------------------------- + + /// @notice Set up the test contract by deploying an instance of + /// {TeamRegistry}. + function setUp() public { + tr = new TeamRegistry(); + } + + // ------------------------------------------------------------------------- + // `createTeam` + // ------------------------------------------------------------------------- + + /// @notice Test that a team leader can not create a new team. + function test_createTeam_TeamLeaderCreatesNewTeam_Fails() public { + _createTeam(); + + vm.expectRevert(abi.encodeWithSelector(TeamRegistry.IsTeamLeader.selector, 1)); + _createTeam(); + } + + /// @notice Test events emitted and state updates upon creating a team. + /// @param _members The member to invite to the team.. + function test_createTeam(address[] calldata _members) public { + vm.assume(_members.length > 0); + + // Test that `address(this)` is not the leader of the team 1 yet, and + // that they're not part of any team yet. + { + assertFalse(tr.getApproved(1, address(this))); + (uint248 teamId, bool isTeamLeader) = tr.getTeam(address(this)); + assertEq(teamId, 0); + assertFalse(isTeamLeader); + } + + // Test that `_members` have not been invited yet. + uint256 length = _members.length; + for (uint256 i; i < length;) { + assertFalse(tr.getApproved(1, _members[i])); + + unchecked { + i++; + } + } + + // Create team and invite `_members` (and check emitted events). + for (uint256 i; i < length;) { + vm.expectEmit(true, true, true, true); + emit SetApprovalForMember(1, _members[i], true); + + unchecked { + i++; + } + } + vm.expectEmit(true, true, true, true); + emit CreateTeam(1, address(this)); + vm.expectEmit(true, true, true, true); + emit SetApprovalForMember(1, address(this), true); + vm.expectEmit(true, true, true, true); + emit TransferTeam(0, 1, address(this)); + tr.createTeam(_members); + + // Test that `address(this)` is team leader and part of team 1. + { + assertTrue(tr.getApproved(1, address(this))); + (uint248 teamId, bool isTeamLeader) = tr.getTeam(address(this)); + assertEq(teamId, 1); + assertTrue(isTeamLeader); + } + + // Test that all addresses in `_members` have been invited. + for (uint256 i; i < length;) { + assertTrue(tr.getApproved(1, _members[i])); + + unchecked { + i++; + } + } + + // Test that team leader cannot create a new team while they're a team + // leader. + vm.expectRevert(abi.encodeWithSelector(TeamRegistry.IsTeamLeader.selector, 1)); + tr.createTeam(_members); + } + + // ------------------------------------------------------------------------- + // `removeMember` and `batchRemoveMember` + // ------------------------------------------------------------------------- + + /// @notice Test that removing a member as not the team leader fails. + function test_removeMember_NotTeamLeader_Fails() public { + _createTeam(); + + // Join team 1 as `makeAddr("sudolabel")`. + vm.prank(makeAddr("sudolabel")); + tr.transferTeam(1); + + vm.expectRevert(abi.encodeWithSelector(TeamRegistry.NotTeamLeader.selector, 1)); + vm.prank(makeAddr("sudolabel")); + tr.removeMember(makeAddr("sudolabel")); + } + + /// @notice Test that batch removing members as not the team leader fails. + function test_batchRemoveMember_NotTeamLeader_Fails() public { + _createTeam(); + + // Join team 1 as `makeAddr("sudolabel")`. + vm.prank(makeAddr("sudolabel")); + tr.transferTeam(1); + + // Check that `makeAddr("sudolabel")` joined team 1, but is not the team + // leader. + { + (uint248 teamId, bool isLeader) = tr.getTeam(makeAddr("sudolabel")); + assertEq(teamId, 1); + assertFalse(isLeader); + } + + address[] memory members = new address[](1); + members[0] = makeAddr("sudolabel"); + vm.expectRevert(abi.encodeWithSelector(TeamRegistry.NotTeamLeader.selector, 1)); + vm.prank(makeAddr("sudolabel")); + tr.batchRemoveMember(members); + } + + /// @notice Test that removing an address that's not part of the team fails. + function test_removeMember_NotTeamMember_Fails() public { + _createTeam(); + + vm.expectRevert( + abi.encodeWithSelector(TeamRegistry.NotInTeam.selector, 1, makeAddr("fiveoutofnine")) + ); + vm.prank(makeAddr("chainlight")); + tr.removeMember(makeAddr("fiveoutofnine")); + } + + /// @notice Test that batch removing addresses that are part of the team + /// fails. + function test_batchRemoveMember_NotTeamMember_Fails() public { + _createTeam(); + + // Join team 1 as `makeAddr("sudolabel")`. + vm.prank(makeAddr("sudolabel")); + tr.transferTeam(1); + + // Create an array where the first address is part of the team, and the + // second address is not part of the team. + address[] memory members = new address[](2); + members[0] = makeAddr("sudolabel"); + members[1] = makeAddr("plotchy"); + vm.expectRevert( + abi.encodeWithSelector(TeamRegistry.NotInTeam.selector, 1, makeAddr("plotchy")) + ); + vm.prank(makeAddr("chainlight")); + tr.batchRemoveMember(members); + } + + /// @notice Test events emitted and state updates upon removing a member + /// from a team. + function test_removeMember() public { + _createTeam(); + + // Join team 1 as `makeAddr("sudolabel")`. + vm.prank(makeAddr("sudolabel")); + tr.transferTeam(1); + + // Test that `makeAddr("sudolabel")` is part of team 1. + { + (uint248 teamId,) = tr.getTeam(makeAddr("sudolabel")); + assertEq(teamId, 1); + } + + // Remove `makeAddr("sudolabel")` from team 1. + vm.expectEmit(true, true, true, true); + emit SetApprovalForMember(1, makeAddr("sudolabel"), false); + vm.expectEmit(true, true, true, true); + emit TransferTeam(1, 0, makeAddr("sudolabel")); + vm.prank(makeAddr("chainlight")); + tr.removeMember(makeAddr("sudolabel")); + + // Test that `makeAddr("sudolabel")` is no longer approved to join the + // team, and that they're no longer part of team . + { + assertFalse(tr.getApproved(1, makeAddr("sudolabel"))); + (uint248 teamId,) = tr.getTeam(makeAddr("sudolabel")); + assertEq(teamId, 0); + } + } + + /// @notice Test events emitted and state updates upon batch removing + /// members from a team. + function test_batchRemoveMember() public { + _createTeam(); + + // Join team 1 as `makeAddr("sudolabel")` and `makeAddr("igorline")`. + vm.prank(makeAddr("sudolabel")); + tr.transferTeam(1); + vm.prank(makeAddr("igorline")); + tr.transferTeam(1); + + // Test that `makeAddr("sudolabel")` is part of team 1. + { + (uint248 teamId,) = tr.getTeam(makeAddr("sudolabel")); + assertEq(teamId, 1); + } + // Test that `makeAddr("igorline")` is part of team 1. + { + (uint248 teamId,) = tr.getTeam(makeAddr("igorline")); + assertEq(teamId, 1); + } + + address[] memory members = new address[](2); + members[0] = makeAddr("sudolabel"); + members[1] = makeAddr("igorline"); + vm.expectEmit(true, true, true, true); + emit SetApprovalForMember(1, makeAddr("sudolabel"), false); + vm.expectEmit(true, true, true, true); + emit TransferTeam(1, 0, makeAddr("sudolabel")); + vm.expectEmit(true, true, true, true); + emit SetApprovalForMember(1, makeAddr("igorline"), false); + vm.expectEmit(true, true, true, true); + emit TransferTeam(1, 0, makeAddr("igorline")); + vm.prank(makeAddr("chainlight")); + tr.batchRemoveMember(members); + + // Test that `makeAddr("sudolabel")` is no longer approved to join the + // team, and that they're no longer part of team. + { + assertFalse(tr.getApproved(1, makeAddr("sudolabel"))); + (uint248 teamId,) = tr.getTeam(makeAddr("sudolabel")); + assertEq(teamId, 0); + } + + // Test that `makeAddr("igorline")` is no longer approved to join the + // team, and that they're no longer part of team. + { + assertFalse(tr.getApproved(1, makeAddr("igorline"))); + (uint248 teamId,) = tr.getTeam(makeAddr("igorline")); + assertEq(teamId, 0); + } + } + + // ------------------------------------------------------------------------- + // `setApprovalForMember` and `batchSetApprovalForMember` + // ------------------------------------------------------------------------- + + /// @notice Test that setting approval for a member as not the team leader + /// fails. + function test_setApprovalForMember_NotTeamLeader_Fails() public { + _createTeam(); + + // Join team 1 as `makeAddr("sudolabel")`. + vm.prank(makeAddr("sudolabel")); + tr.transferTeam(1); + + vm.expectRevert(abi.encodeWithSelector(TeamRegistry.NotTeamLeader.selector, 1)); + vm.prank(makeAddr("sudolabel")); + tr.setApprovalForMember(makeAddr("sudolabel"), true); + } + + /// @notice Test that batch setting approval for members as not the team + /// leader fails. + function test_batchSetApprovalForMember_NotTeamLeader_Fails() public { + _createTeam(); + + // Join team 1 as `makeAddr("sudolabel")`. + vm.prank(makeAddr("sudolabel")); + tr.transferTeam(1); + + // Check that `makeAddr("sudolabel")` joined team 1, but is not the team + // leader. + { + (uint248 teamId, bool isLeader) = tr.getTeam(makeAddr("sudolabel")); + assertEq(teamId, 1); + assertFalse(isLeader); + } + + address[] memory members = new address[](1); + members[0] = makeAddr("sudolabel"); + vm.expectRevert(abi.encodeWithSelector(TeamRegistry.NotTeamLeader.selector, 1)); + vm.prank(makeAddr("sudolabel")); + tr.batchSetApprovalForMember(members, false); + } + + /// @notice Test events emitted and state updates upon setting approval true + /// for a member, and then setting approval false for a member. + function test_setApprovalForMember() public { + _createTeam(); + + // Test that `makeAddr("fiveoutofnine")` is not approved to join team 1 + // yet. + assertFalse(tr.getApproved(1, makeAddr("fiveoutofnine"))); + + // Approve `makeAddr("fiveoutofnine")` to join team 1. + vm.expectEmit(true, true, true, true); + emit SetApprovalForMember(1, makeAddr("fiveoutofnine"), true); + vm.prank(makeAddr("chainlight")); + tr.setApprovalForMember(makeAddr("fiveoutofnine"), true); + + // Test that `makeAddr("fiveoutofnine")` is approved to join team 1. + assertTrue(tr.getApproved(1, makeAddr("fiveoutofnine"))); + + // Disapprove `makeAddr("fiveoutofnine")` to join team 1. + vm.expectEmit(true, true, true, true); + emit SetApprovalForMember(1, makeAddr("fiveoutofnine"), false); + vm.prank(makeAddr("chainlight")); + tr.setApprovalForMember(makeAddr("fiveoutofnine"), false); + + // Test that `makeAddr("fiveoutofnine")` is no longer approved to join + // team 1. + assertFalse(tr.getApproved(1, makeAddr("fiveoutofnine"))); + } + + /// @notice Test events emitted and state updates upon batch setting + /// approval true for members, and then setting approval false for the same + /// members. + function test_batchSetApprovalForMember() public { + _createTeam(); + + // Test that `makeAddr("fiveoutofnine")` and `makeAddr("plotchy") are + // both not approved to join team 1 yet. + assertFalse(tr.getApproved(1, makeAddr("fiveoutofnine"))); + assertFalse(tr.getApproved(1, makeAddr("plotchy"))); + + address[] memory members = new address[](2); + members[0] = makeAddr("fiveoutofnine"); + members[1] = makeAddr("plotchy"); + vm.expectEmit(true, true, true, true); + emit SetApprovalForMember(1, makeAddr("fiveoutofnine"), true); + vm.expectEmit(true, true, true, true); + emit SetApprovalForMember(1, makeAddr("plotchy"), true); + vm.prank(makeAddr("chainlight")); + tr.batchSetApprovalForMember(members, true); + + // Test that `makeAddr("fiveoutofnine")` and `makeAddr("plotchy")` are + // both approved to join team 1. + assertTrue(tr.getApproved(1, makeAddr("fiveoutofnine"))); + assertTrue(tr.getApproved(1, makeAddr("plotchy"))); + + // Disapprove `makeAddr("fiveoutofnine")` to join team 1. + vm.expectEmit(true, true, true, true); + emit SetApprovalForMember(1, makeAddr("fiveoutofnine"), false); + vm.expectEmit(true, true, true, true); + emit SetApprovalForMember(1, makeAddr("plotchy"), false); + vm.prank(makeAddr("chainlight")); + tr.batchSetApprovalForMember(members, false); + + // Test that `makeAddr("fiveoutofnine")` and `makeAddr("plotchy")` are + // no longer approved to join team 1. + assertFalse(tr.getApproved(1, makeAddr("fiveoutofnine"))); + assertFalse(tr.getApproved(1, makeAddr("plotchy"))); + } + + // ------------------------------------------------------------------------- + // `transferTeam` + // ------------------------------------------------------------------------- + + /// @notice Test that transferring teams as a team leader fails. + function test_transferTeam_IsTeamLeader_Fails() public { + _createTeam(); + + vm.expectRevert(abi.encodeWithSelector(TeamRegistry.IsTeamLeader.selector, 1)); + vm.prank(makeAddr("chainlight")); + tr.transferTeam(1); + } + + /// @notice Test that transferring to a team that an address is not approved + /// to join fails. + function test_transferTeam_ApprovalFalse_Fails() public { + _createTeam(); + + vm.expectRevert(TeamRegistry.Unauthorized.selector); + vm.prank(makeAddr("fiveoutofnine")); + tr.transferTeam(1); + } + + /// @notice Test events emitted and state updates upon transferring teams. + function test_transferTeam() public { + _createTeam(); + + // Test that `makeAddr("sudolabel")` is not part of team 1 yet. + { + (uint248 teamId,) = tr.getTeam(makeAddr("sudolabel")); + assertEq(teamId, 0); + } + + // Transfer team 1 to `makeAddr("sudolabel")`. + vm.expectEmit(true, true, true, true); + emit TransferTeam(0, 1, makeAddr("sudolabel")); + vm.prank(makeAddr("sudolabel")); + tr.transferTeam(1); + + // Test that `makeAddr("sudolabel")` is part of team 1. + { + (uint248 teamId,) = tr.getTeam(makeAddr("sudolabel")); + assertEq(teamId, 1); + } + } + + // ------------------------------------------------------------------------- + // `transferTeamLeadership` + // ------------------------------------------------------------------------- + + /// @notice Test that transferring team leadership as not the team leader + /// fails. + function test_transferTeamLeadership_NotTeamLeader_Fails() public { + _createTeam(); + + // Join team 1 as `makeAddr("sudolabel")`. + vm.prank(makeAddr("sudolabel")); + tr.transferTeam(1); + + vm.expectRevert(abi.encodeWithSelector(TeamRegistry.NotTeamLeader.selector, 1)); + vm.prank(makeAddr("sudolabel")); + tr.transferTeamLeadership(makeAddr("sudolabel")); + } + + /// @notice Test that transferring team leadership to an address that's not + /// part of the team fails. + function test_transferTeamLeadership_NotTeamMember_Fails() public { + _createTeam(); + + vm.expectRevert( + abi.encodeWithSelector(TeamRegistry.NotInTeam.selector, 1, makeAddr("fiveoutofnine")) + ); + vm.prank(makeAddr("chainlight")); + tr.transferTeamLeadership(makeAddr("fiveoutofnine")); + } + + /// @notice Test events emitted and state updates upon transferring team + /// leadership. + function test_transferTeamLeadership() public { + _createTeam(); + + // Join team 1 as `makeAddr("sudolabel")`. + vm.prank(makeAddr("sudolabel")); + tr.transferTeam(1); + + // Test that `makeAddr("chainlight")` is the leader of team 1, and + // `makeAddr("sudolabel")` is not the team leader of team 1 yet. + { + (uint248 teamId, bool isTeamLeader) = tr.getTeam(makeAddr("chainlight")); + assertEq(teamId, 1); + assertTrue(isTeamLeader); + } + { + (uint248 teamId, bool isTeamLeader) = tr.getTeam(makeAddr("sudolabel")); + assertEq(teamId, 1); + assertFalse(isTeamLeader); + } + + // Transfer team leadership to `makeAddr("sudolabel")` (and check + // emitted events). + vm.expectEmit(true, true, true, true); + emit TransferTeamLeadership(1, makeAddr("chainlight"), makeAddr("sudolabel")); + vm.prank(makeAddr("chainlight")); + tr.transferTeamLeadership(makeAddr("sudolabel")); + + // Test that `makeAddr("chainlight)` is still part of the team (but no + // longer the leader), and `makeAddr("sudolabel")` is the team leader of + // team 1. + { + (uint248 teamId, bool isTeamLeader) = tr.getTeam(makeAddr("chainlight")); + assertEq(teamId, 1); + assertFalse(isTeamLeader); + } + { + (uint248 teamId, bool isTeamLeader) = tr.getTeam(makeAddr("sudolabel")); + assertEq(teamId, 1); + assertTrue(isTeamLeader); + } + } + + // ------------------------------------------------------------------------- + // Helper functions + // ------------------------------------------------------------------------- + + /// @notice Create a team as `makeAddr("chainlight")`, and invite + /// `makeAddr("sudolabel")`, `makeAddr("igorline")`, `makeAddr("jinu")`, + /// `makeAddr("minimooger")`, and `makeAddr("kalzak")`. + function _createTeam() internal { + address[] memory members = new address[](5); + members[0] = makeAddr("sudolabel"); + members[1] = makeAddr("igorline"); + + vm.prank(makeAddr("chainlight")); + tr.createTeam(members); + } +}