diff --git a/.gitignore b/.gitignore index 8e91b0e9..2b69fa71 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ data/snapshotBalance data/holders flattened distribution-data*.json +.DS_Store #Hardhat files cache diff --git a/contracts/competition/Competition.sol b/contracts/competition/Competition.sol new file mode 100644 index 00000000..2d016225 --- /dev/null +++ b/contracts/competition/Competition.sol @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.6.0; + +import "../referrals/interfaces/IReferralStorage.sol"; +import "../access/Governable.sol"; + +contract Competition is Governable { + struct Team { + address leader; + string name; + address[] members; + } + + struct Competition { + uint start; + uint end; + uint maxTeamSize; + uint8 competitionType; + mapping(address => Team) teams; + mapping(string => bool) teamNames; + mapping(address => address) memberTeams; + mapping(address => address) joinRequests; + mapping(address => mapping(address => bytes32)) joinRequestsReferralCodes; + } + + Competition[] public competitions; + IReferralStorage public referralStorage; + + event TeamCreated(uint index, address leader, string name); + event JoinRequestCreated(uint index, address member, address leader, bytes32 referralCode); + event JoinRequestCanceled(uint index, address member); + event JoinRequestApproved(uint index, address member, address leader); + event MemberRemoved(uint index, address leader, address member); + event CompetitionCreated(uint index, uint start, uint end, uint maxTeamSize, uint8 competitionType); + event CompetitionUpdated(uint index, uint start, uint end, uint maxTeamSize, uint8 competitionType); + event CompetitionRemoved(uint index); + + modifier registrationIsOpen(uint competitionIndex) { + require(competitions[competitionIndex].start > block.timestamp, "Competition: Registration is closed."); + _; + } + + modifier isNotMember(uint competitionIndex) { + require(competitions[competitionIndex].memberTeams[msg.sender] == address(0), "Competition: Team members are not allowed."); + _; + } + + modifier competitionExists(uint index) { + require(competitions.length > index && competitions[index].start > 0, "Competition: The competition does not exist."); + _; + } + + modifier teamsAreAllowed(uint index) { + require(competitions[competitionIndex].competitionType !== 0, "Competition: Team are not allowed."); + _; + } + + constructor(IReferralStorage _referralStorage) public { + referralStorage = _referralStorage; + } + + function createCompetition(uint start, uint end, uint maxTeamSize, uint8 competitionType) external onlyGov { + _validateCompetitionParameters(start, end, maxTeamSize); + + competitions.push(Competition(start, end, maxTeamSize, competitionType)); + + emit CompetitionCreated(competitions.length - 1, start, end, maxTeamSize); + } + + function updateCompetition(uint index, uint start, uint end, uint maxTeamSize, uint8 competitionType) external onlyGov competitionExists(index) { + _validateCompetitionParameters(start, end, maxTeamSize); + + competitions[index].start = start; + competitions[index].end = end; + competitions[index].maxTeamSize = maxTeamSize; + competitions[index].competitionType = competitionType; + + emit CompetitionUpdated(index, start, end, maxTeamSize, competitionType); + } + + function removeCompetition(uint index) external onlyGov competitionExists(index) { + require(competitions[index].start > block.timestamp, "Competition: Competition is active."); + + delete competitions[index]; + + emit CompetitionRemoved(index); + } + + function createTeam(uint competitionIndex, string calldata name) external registrationIsOpen(competitionIndex) isNotMember(competitionIndex) teamsAreAllowed(competitionIndex) { + Competition storage competition = competitions[competitionIndex]; + + require(!competition.teamNames[name], "Competition: Team name already registered."); + + Team storage team = competition.teams[msg.sender]; + team.leader = msg.sender; + team.name = name; + team.members.push(msg.sender); + + competition.teamNames[name] = true; + competition.memberTeams[msg.sender] = msg.sender; + + emit TeamCreated(competitionIndex, msg.sender, name); + } + + function createJoinRequest(uint competitionIndex, address leaderAddress, bytes32 referralCode) external registrationIsOpen(competitionIndex) isNotMember(competitionIndex) teamsAreAllowed(competitionIndex) { + Competition storage competition = competitions[competitionIndex]; + + require(competition.memberTeams[msg.sender] == address(0), "Competition: You can't join multiple teams."); + require(competition.teams[leaderAddress].leader != address(0), "Competition: The team does not exist."); + + if (referralCode != bytes32(0)) { + require(referralStorage.codeOwners(referralCode) != address(0), "Competition: The referral code does not exist."); + } + + competition.joinRequests[msg.sender] = leaderAddress; + competition.joinRequestsReferralCodes[msg.sender][leaderAddress] = referralCode; + + emit JoinRequestCreated(competitionIndex, msg.sender, leaderAddress, referralCode); + } + + function approveJoinRequest(uint competitionIndex, address[] calldata memberAddresses) external registrationIsOpen(competitionIndex) teamsAreAllowed(competitionIndex) { + Competition storage competition = competitions[competitionIndex]; + + for (uint i = 0; i < memberAddresses.length; i++) { + address memberAddress = memberAddresses[i]; + require(competition.joinRequests[memberAddress] == msg.sender, "Competition: Member did not apply."); + require(competition.memberTeams[memberAddress] == address(0), "Competition: Member already joined a team."); + require(competition.teams[msg.sender].members.length < competition.maxTeamSize, "Competition: Team is full."); + + if (competition.joinRequestsReferralCodes[memberAddress][msg.sender] != bytes32(0)) { + referralStorage.setTraderReferralCode(memberAddress, competition.joinRequestsReferralCodes[memberAddress][msg.sender]); + } + + competition.teams[msg.sender].members.push(memberAddress); + competition.memberTeams[memberAddress] = msg.sender; + competition.joinRequests[memberAddress] = address(0); + + emit JoinRequestApproved(competitionIndex, memberAddress, msg.sender); + } + } + + function cancelJoinRequest(uint competitionIndex) external registrationIsOpen(competitionIndex) teamsAreAllowed(competitionIndex) { + competitions[competitionIndex].joinRequests[msg.sender] = address(0); + emit JoinRequestCanceled(competitionIndex, msg.sender); + } + + function removeMember(uint competitionIndex, address leaderAddress, address memberAddress) external registrationIsOpen(competitionIndex) teamsAreAllowed(competitionIndex) { + Competition storage competition = competitions[competitionIndex]; + + require(competition.memberTeams[memberAddress] == msg.sender || memberAddress == msg.sender, "Competition: You are not allowed to remove this member."); + + address[] memory oldMembers = competition.teams[leaderAddress].members; + delete competition.teams[leaderAddress].members; + for (uint i = 0; i < oldMembers.length; i++) { + if (oldMembers[i] != memberAddress) { + competition.teams[leaderAddress].members.push(oldMembers[i]); + } + } + + competition.memberTeams[memberAddress] = address(0); + + emit MemberRemoved(competitionIndex, leaderAddress, memberAddress); + } + + function getTeam(uint competitionIndex, address _leaderAddress) external view returns (address leaderAddress, string memory name) { + Team memory team = competitions[competitionIndex].teams[_leaderAddress]; + return (team.leader, team.name); + } + + function getTeamMembers(uint competitionIndex, address leaderAddress, uint start, uint offset) external view returns (address[] memory members) { + address[] memory members = competitions[competitionIndex].teams[leaderAddress].members; + address[] memory result = new address[](offset); + + for (uint i = start; i < start + offset && i < members.length; i++) { + result[i] = members[i]; + } + + return result; + } + + function getMemberTeam(uint competitionIndex, address memberAddress) external view returns (address leaderAddress) { + return competitions[competitionIndex].memberTeams[memberAddress]; + } + + function getJoinRequest(uint competitionIndex, address memberAddress) external view returns (address leaderAddress) { + return competitions[competitionIndex].joinRequests[memberAddress]; + } + + function validateName(uint competitionIndex, string calldata name) external view returns (bool isValid) { + return !competitions[competitionIndex].teamNames[name]; + } + + function _validateCompetitionParameters(uint start, uint end, uint maxTeamSize) internal { + require(start > block.timestamp, "Competition: Start time must be in the future."); + require(end > start, "Competition: End time must be greater than start time."); + require(maxTeamSize > 0, "Competition: Max team size must be greater than zero."); + } +} diff --git a/scripts/competition/createCompetition.js b/scripts/competition/createCompetition.js new file mode 100644 index 00000000..3c5c11f5 --- /dev/null +++ b/scripts/competition/createCompetition.js @@ -0,0 +1,19 @@ +const { ethers } = require("hardhat"); +const { contractAt, sendTxn } = require("../shared/helpers") + +async function main() { + const competition = await contractAt("Competition", "0x271B8D7b97A07207BAd07dc577F6D29D6a368C56"); + + await sendTxn(competition.createCompetition( + 1663884000, + 1664488800, + 5 + ), "competition.createCompetition(start, end, maxTeamSize)") +} + +main() + .then(() => process.exit(0)) + .catch(error => { + console.error(error) + process.exit(1) + }) diff --git a/scripts/competition/updateCompetition.js b/scripts/competition/updateCompetition.js new file mode 100644 index 00000000..f78b60a5 --- /dev/null +++ b/scripts/competition/updateCompetition.js @@ -0,0 +1,19 @@ +const { contractAt, sendTxn } = require("../shared/helpers") + +async function main() { + const competition = await contractAt("Competition", "0x17fb5AEEF7221353B6B2D12EDDa0Dd5655Ec25b2"); + + await sendTxn(competition.updateCompetition( + 0, + 1672527599, + 1704063599, + 10 + ), "competition.updateCompetition(index, start, end, maxTeamSize)") +} + +main() + .then(() => process.exit(0)) + .catch(error => { + console.error(error) + process.exit(1) + }) diff --git a/scripts/core/deployCompetition.js b/scripts/core/deployCompetition.js new file mode 100644 index 00000000..7b00f5ea --- /dev/null +++ b/scripts/core/deployCompetition.js @@ -0,0 +1,45 @@ +const { deployContract, contractAt } = require("../shared/helpers") + +const network = (process.env.HARDHAT_NETWORK || 'mainnet'); + +async function getArbValues() { + const positionRouter = await contractAt("PositionRouter", "0x3D6bA331e3D9702C5e8A8d254e5d8a285F223aba") + + return { positionRouter } +} + +async function getAvaxValues() { + const positionRouter = await contractAt("PositionRouter", "0x195256074192170d1530527abC9943759c7167d8") + + return { positionRouter } +} + +// async function getArbitrumTestnetValues() { +// const positionRouter = await contractAt("PositionRouter", "0x195256074192170d1530527abC9943759c7167d8") + +// return { positionRouter } +// } + +async function getValues() { + if (network === "arbitrum") { + return getArbValues() + } + + if (network === "avax") { + return getAvaxValues() + } +} + +async function main() { + // const { positionRouter } = await getValues() + const referralStorage = await contractAt("ReferralStorage", "0x902B74dAe2fff3BA564BDa930A7D687b84e0E9cC") + + await deployContract("Competition", [ referralStorage.address ]); +} + +main() + .then(() => process.exit(0)) + .catch(error => { + console.error(error) + process.exit(1) + }) diff --git a/test/competition/Competition.js b/test/competition/Competition.js new file mode 100644 index 00000000..341b2561 --- /dev/null +++ b/test/competition/Competition.js @@ -0,0 +1,209 @@ +const { ADDRESS_ZERO } = require("@uniswap/v3-sdk") +const { expect, use } = require("chai") +const { solidity } = require("ethereum-waffle") +const { ethers } = require("hardhat") +const { deployContract } = require("../shared/fixtures") +const { getBlockTime, sleep } = require("../shared/utilities") + +use(solidity) + +const { keccak256 } = ethers.utils + +describe("Competition", function () { + const provider = waffle.provider + const [wallet, user0, user1, user2, user3] = provider.getWallets() + let contract + let ts + let referralStorage + let code = keccak256("0xFF") + let nullCode = ethers.constants.HashZero + + async function getTeamMembers(index, addr) + { + let start = 0 + const offset = 100 + const result = [] + + while (true) { + let res = await contract.getTeamMembers(index, addr, start, offset) + res = res.filter(addr => addr !== ADDRESS_ZERO) + + res.forEach(r => { + result.push(r) + }) + + if (res.length < offset) { + break; + } + } + + return result + } + + beforeEach(async () => { + ts = await getBlockTime(provider) + referralStorage = await deployContract("ReferralStorage", []) + contract = await deployContract("Competition", [referralStorage.address]); + await referralStorage.setHandler(contract.address, true) + await referralStorage.registerCode(code) + await contract.createCompetition(ts + 10, ts + 20, 10) + }) + + it("allows owner to create competition", async () => { + await contract.connect(wallet).createCompetition(ts + 10, ts + 20, 10) + }) + + it("disable non owners to set competition details", async () => { + await expect(contract.connect(user0).createCompetition(ts + 10, ts + 20, 10)).to.be.revertedWith("Governable: forbidden") + }) + + it("disable people to register teams after registration time", async () => { + const ts = await getBlockTime(provider) + await contract.connect(wallet).createCompetition(ts + 2, ts + 60, 10) + await sleep(2000); + await expect(contract.connect(user0).createTeam(1, "1")).to.be.revertedWith("Competition: Registration is closed.") + }) + + it("allows people to register teams in times", async () => { + await contract.connect(user0).createTeam(0, "1") + }) + + it("disable people to register multiple teams", async () => { + await contract.connect(user0).createTeam(0, "1") + await expect(contract.connect(user0).createTeam(0, "2")).to.be.revertedWith("Competition: Team members are not allowed.") + }) + + // it("disable people to register a team with non existing referral code", async () => { + // await expect(contract.connect(user0).createTeam(0, "1", keccak256("0xFE"))).to.be.revertedWith("Competition: Referral code does not exist.") + // }) + + it("disable multiple teams with the same name", async () => { + await contract.connect(user0).createTeam(0, "1") + await expect(contract.connect(user1).createTeam(0, "1")).to.be.revertedWith("Competition: Team name already registered.") + }) + + it("allows people to create join requests", async () => { + await contract.connect(user0).createTeam(0, "1") + await contract.connect(user1).createJoinRequest(0, user0.address, nullCode); + await contract.connect(user2).createJoinRequest(0, user0.address, code); + }) + + it("allows people to replace join requests", async () => { + await contract.connect(user0).createTeam(0, "1") + await contract.connect(user1).createTeam(0, "2") + await contract.connect(user2).createJoinRequest(0, user0.address, nullCode) + await contract.connect(user2).createJoinRequest(0, user1.address, nullCode) + }) + + it("allow people to cancel join requests", async () => { + await contract.connect(user0).createTeam(0, "1") + await contract.connect(user1).createJoinRequest(0, user0.address, nullCode) + await contract.connect(user1).cancelJoinRequest(0) + await expect(contract.connect(user0).approveJoinRequest(0, [user1.address])).to.be.revertedWith("Competition: Member did not apply.") + }) + + it("disable team members to create join requests", async () => { + await contract.connect(user0).createTeam(0, "1") + await contract.connect(user1).createTeam(0, "2") + await expect(contract.connect(user0).createJoinRequest(0, user1.address, nullCode)).to.be.revertedWith("Competition: Team members are not allowed.") + }) + + it("allows team leaders to accept requests", async () => { + await contract.connect(user0).createTeam(0, "1") + await contract.connect(user1).createJoinRequest(0, user0.address, code) + await contract.connect(user2).createJoinRequest(0, user0.address, code) + await contract.connect(user0).approveJoinRequest(0, [user1.address, user2.address]) + const members = await getTeamMembers(0, user0.address) + expect(members).to.include(user1.address) + expect(members).to.include(user2.address) + }) + + it("disallow leaders to accept non existant join request", async () => { + await contract.connect(user0).createTeam(0, "1") + await expect(contract.connect(user0).approveJoinRequest(0, [user1.address])).to.be.revertedWith("Competition: Member did not apply.") + }) + + it("disallow leaders to accept members after registration time", async () => { + const ts = await getBlockTime(provider) + await contract.connect(wallet).createCompetition(ts + 2, ts + 10, 10) + await sleep(2000) + await expect(contract.connect(user0).createTeam(1, "1")).to.be.revertedWith("Competition: Registration is closed.") + }) + + it("allow leaders to kick members", async () => { + await contract.updateCompetition(0, ts + 1000, ts + 2000, 10); + await contract.connect(user0).createTeam(0, "1") + await contract.connect(user1).createJoinRequest(0, user0.address, nullCode) + await contract.connect(user0).approveJoinRequest(0, [user1.address]) + await contract.connect(user2).createJoinRequest(0, user0.address, nullCode) + await contract.connect(user0).approveJoinRequest(0, [user2.address]) + let members = await getTeamMembers(0, user0.address) + expect(members).to.include(user1.address) + expect(members).to.include(user2.address) + await contract.connect(user0).removeMember(0, user0.address, user1.address) + members = await getTeamMembers(0, user0.address) + expect(members).to.include(user2.address) + expect(members).to.not.include(user1.address) + const team = await contract.getMemberTeam(0, user1.address) + expect(team).to.be.equal(ethers.constants.AddressZero) + }) + + it("allow members to kick themselves", async () => { + await contract.connect(user0).createTeam(0, "1") + await contract.connect(user1).createJoinRequest(0, user0.address, nullCode) + await contract.connect(user0).approveJoinRequest(0, [user1.address]) + await contract.connect(user1).removeMember(0, user0.address, user1.address) + members = await getTeamMembers(0, user0.address) + expect(members).to.not.include(user1.address) + const team = await contract.getMemberTeam(0, user1.address) + expect(team).to.be.equal(ethers.constants.AddressZero) + }) + + it("allow owner to change competition", async () => { + const newStart = ts + 60 + const newEnd = ts + 120 + await contract.connect(wallet).updateCompetition(0, newStart, newEnd, 5); + const res = await contract.competitions(0) + expect(res.start.toNumber()).to.be.equal(newStart) + expect(res.end.toNumber()).to.be.equal(newEnd) + expect(res.maxTeamSize).to.be.equal(5) + }) + + it("disallow non owners to change competition", async () => { + await expect(contract.connect(user0).updateCompetition(0, ts + 60, ts + 12, 5)).to.be.revertedWith("Governable: forbidden") + }) + + it("disallow leader to accept join request if team is full", async () => { + const ts = await getBlockTime(provider) + + await contract.connect(wallet).updateCompetition(0, ts + 10, ts + 20, 3) + await contract.connect(user0).createTeam(0, "1") + + await contract.connect(user1).createJoinRequest(0, user0.address, nullCode) + await contract.connect(user0).approveJoinRequest(0, [user1.address]) + + await contract.connect(user2).createJoinRequest(0, user0.address, nullCode) + await contract.connect(user0).approveJoinRequest(0, [user2.address]) + + await contract.connect(user3).createJoinRequest(0, user0.address, nullCode) + await expect(contract.connect(user0).approveJoinRequest(0, [user3.address])).to.be.revertedWith("Competition: Team is full.") + }) + + it("allow owner to delete competition if it is not started", async () => { + await contract.removeCompetition(0) + const ts = await getBlockTime(provider) + await contract.createCompetition(ts + 2, ts + 10, 10) + await sleep(2000) + await expect(contract.removeCompetition(1)).to.be.revertedWith("Competition: Competition is active.") + await contract.updateCompetition(1, ts + 10, ts + 20, 10) + await expect(contract.connect(user1).removeCompetition(1)).to.be.revertedWith("Governable: forbidden") + }) + + it("sets the referral code to the members when the request is approved", async () => { + await contract.createTeam(0, "a") + await contract.connect(user0).createJoinRequest(0, wallet.address, code) + await contract.approveJoinRequest(0, [user0.address]) + const traderCode = await referralStorage.getTraderReferralInfo(user0.address) + expect(traderCode[0]).to.be.equal(code) + }) +}); diff --git a/test/shared/utilities.js b/test/shared/utilities.js index d97ad376..e443ee87 100644 --- a/test/shared/utilities.js +++ b/test/shared/utilities.js @@ -115,6 +115,10 @@ function getPriceBits(prices) { return priceBits.toString() } +async function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + module.exports = { newWallet, maxUint256, @@ -129,5 +133,6 @@ module.exports = { getTxnBalances, print, getPriceBitArray, - getPriceBits + getPriceBits, + sleep }