diff --git a/packages/addresses/package.json b/packages/addresses/package.json index 0e081e1c..59ab7864 100644 --- a/packages/addresses/package.json +++ b/packages/addresses/package.json @@ -22,4 +22,4 @@ "vite": "^3.1.0", "vite-plugin-dts": "^1.4.1" } -} \ No newline at end of file +} diff --git a/packages/zevm-app-contracts/contracts/zeta-points/InvitationManager.sol b/packages/zevm-app-contracts/contracts/zeta-points/InvitationManager.sol new file mode 100644 index 00000000..50f43173 --- /dev/null +++ b/packages/zevm-app-contracts/contracts/zeta-points/InvitationManager.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.7; + +contract InvitationManager { + /* An ECDSA signature. */ + struct Signature { + uint8 v; + bytes32 r; + bytes32 s; + } + // Records the timestamp when a particular user gets verified. + mapping(address => uint256) public userVerificationTimestamps; + + // Records the timestamp when a particular user accepted an invitation from an inviter. + mapping(address => mapping(address => uint256)) public acceptedInvitationsTimestamp; + + // Store invitees for each inviter + mapping(address => address[]) public inviteeLists; + + // Total invites accepted by day (using the start timestamp of each day as key) + mapping(uint256 => uint256) public totalInvitesByDay; + + // Total invites accepted by inviter by day (using the start timestamp of each day as key) + mapping(address => mapping(uint256 => uint256)) public totalInvitesByInviterByDay; + + error UserAlreadyVerified(); + error UnrecognizedInvitation(); + error IndexOutOfBounds(); + error CanNotInviteYourself(); + + event UserVerified(address indexed userAddress, uint256 verifiedAt); + event InvitationAccepted(address indexed inviter, address indexed invitee, uint256 index, uint256 acceptedAt); + + function _markAsVerified(address user) internal { + // Check if the user is already verified + if (userVerificationTimestamps[user] > 0) revert UserAlreadyVerified(); + + userVerificationTimestamps[user] = block.timestamp; + emit UserVerified(user, block.timestamp); + } + + function markAsVerified() external { + _markAsVerified(msg.sender); + } + + function hasBeenVerified(address userAddress) external view returns (bool) { + return userVerificationTimestamps[userAddress] > 0; + } + + function getVerifiedTimestamp(address userAddress) external view returns (uint256) { + return userVerificationTimestamps[userAddress]; + } + + function _verifySignature(address inviter, Signature calldata signature) private pure { + bytes32 payloadHash = keccak256(abi.encode(inviter)); + bytes32 messageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", payloadHash)); + + address messageSigner = ecrecover(messageHash, signature.v, signature.r, signature.s); + if (inviter != messageSigner) revert UnrecognizedInvitation(); + } + + function confirmAndAcceptInvitation(address inviter, Signature calldata signature) external { + if (inviter == msg.sender) revert CanNotInviteYourself(); + if (userVerificationTimestamps[inviter] == 0) revert UnrecognizedInvitation(); + _verifySignature(inviter, signature); + + acceptedInvitationsTimestamp[inviter][msg.sender] = block.timestamp; + _markAsVerified(msg.sender); + + // Add the invitee to the inviter's list + inviteeLists[inviter].push(msg.sender); + + uint256 dayStartTimestamp = (block.timestamp / 86400) * 86400; // Normalize to the start of the day + + totalInvitesByDay[dayStartTimestamp]++; + totalInvitesByInviterByDay[inviter][dayStartTimestamp]++; + + emit InvitationAccepted(inviter, msg.sender, inviteeLists[inviter].length - 1, block.timestamp); + } + + function getInviteeCount(address inviter) external view returns (uint256) { + return inviteeLists[inviter].length; + } + + function getInviteeAtIndex(address inviter, uint256 index) external view returns (address) { + if (index >= inviteeLists[inviter].length) revert IndexOutOfBounds(); + return inviteeLists[inviter][index]; + } + + function getTotalInvitesOnDay(uint256 dayStartTimestamp) external view returns (uint256) { + return totalInvitesByDay[dayStartTimestamp]; + } + + function getInvitesByInviterOnDay(address inviter, uint256 dayStartTimestamp) external view returns (uint256) { + return totalInvitesByInviterByDay[inviter][dayStartTimestamp]; + } +} diff --git a/packages/zevm-app-contracts/scripts/zeta-points/deploy.ts b/packages/zevm-app-contracts/scripts/zeta-points/deploy.ts new file mode 100644 index 00000000..d7cefbbe --- /dev/null +++ b/packages/zevm-app-contracts/scripts/zeta-points/deploy.ts @@ -0,0 +1,26 @@ +import { isNetworkName } from "@zetachain/addresses"; +import { saveAddress } from "@zetachain/addresses-tools"; +import { ethers, network } from "hardhat"; + +import { InvitationManager__factory, UserVerificationRegistry__factory } from "../../typechain-types"; + +const networkName = network.name; + +const invitationManager = async () => { + const InvitationManagerFactory = (await ethers.getContractFactory("InvitationManager")) as InvitationManager__factory; + + const invitationManager = await InvitationManagerFactory.deploy(); + await invitationManager.deployed(); + console.log("InvitationManager deployed to:", invitationManager.address); + // saveAddress("invitationManager", invitationManager.address); +}; + +const main = async () => { + if (!isNetworkName(networkName)) throw new Error("Invalid network name"); + await invitationManager(); +}; + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/packages/zevm-app-contracts/test/zeta-points/InvitationManager.ts b/packages/zevm-app-contracts/test/zeta-points/InvitationManager.ts new file mode 100644 index 00000000..b169e037 --- /dev/null +++ b/packages/zevm-app-contracts/test/zeta-points/InvitationManager.ts @@ -0,0 +1,137 @@ +import { expect, use } from "chai"; +import { solidity } from "ethereum-waffle"; +use(solidity); +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { ethers } from "hardhat"; + +import { InvitationManager } from "../../typechain-types"; +import { getInvitationSig } from "./test.helpers"; + +describe("InvitationManager Contract test", () => { + let invitationManager: InvitationManager, + inviter: SignerWithAddress, + invitee: SignerWithAddress, + addrs: SignerWithAddress[]; + + beforeEach(async () => { + [inviter, invitee, ...addrs] = await ethers.getSigners(); + const InvitationManager = await ethers.getContractFactory("InvitationManager"); + //@ts-ignore + invitationManager = await InvitationManager.deploy(); + await invitationManager.markAsVerified(); + }); + + describe("True", () => { + it("Should be true", async () => { + expect(true).to.equal(true); + }); + }); + + describe("Invitations test", () => { + it("Should verify an invitation and store it", async () => { + const sig = await getInvitationSig(inviter); + + const hasBeenVerifiedBefore = await invitationManager.hasBeenVerified(invitee.address); + await expect(hasBeenVerifiedBefore).to.be.eq(false); + + const tx = await invitationManager.connect(invitee).confirmAndAcceptInvitation(inviter.address, sig); + const rec = await tx.wait(); + + const block = await ethers.provider.getBlock(rec.blockNumber); + + const invitation = await invitationManager.acceptedInvitationsTimestamp(inviter.address, invitee.address); + await expect(invitation).to.be.eq(block.timestamp); + + const invitationCount = await invitationManager.getInviteeCount(inviter.address); + await expect(invitationCount).to.be.eq(1); + + const hasBeenVerifiedAfter = await invitationManager.hasBeenVerified(invitee.address); + await expect(hasBeenVerifiedAfter).to.be.eq(true); + }); + + it("Should revert if invitation is invalid", async () => { + const sig = await getInvitationSig(inviter); + const tx = invitationManager.connect(invitee).confirmAndAcceptInvitation(addrs[0].address, sig); + await expect(tx).to.be.revertedWith("UnrecognizedInvitation"); + }); + + it("Should revert if inviter has not been verified", async () => { + const sig = await getInvitationSig(addrs[0]); + const tx = invitationManager.connect(invitee).confirmAndAcceptInvitation(addrs[0].address, sig); + await expect(tx).to.be.revertedWith("UnrecognizedInvitation"); + }); + + it("Should revert if invitation is already accepted", async () => { + const sig = await getInvitationSig(inviter); + await invitationManager.connect(invitee).confirmAndAcceptInvitation(inviter.address, sig); + const tx = invitationManager.connect(invitee).confirmAndAcceptInvitation(inviter.address, sig); + await expect(tx).to.be.revertedWith("UserAlreadyVerified"); + }); + + it("Should count only for today if I just accepted", async () => { + const sig = await getInvitationSig(inviter); + const tx = await invitationManager.connect(invitee).confirmAndAcceptInvitation(inviter.address, sig); + const rec = await tx.wait(); + + const block = await ethers.provider.getBlock(rec.blockNumber); + + const invitation = await invitationManager.acceptedInvitationsTimestamp(inviter.address, invitee.address); + await expect(invitation).to.be.eq(block.timestamp); + + const invitationCount = await invitationManager.getInviteeCount(inviter.address); + await expect(invitationCount).to.be.eq(1); + + const now = block.timestamp; + const todayTimestamp = Math.floor(now / 86400) * 86400; + const invitationCountToday = await invitationManager.getTotalInvitesOnDay(todayTimestamp); + await expect(invitationCountToday).to.be.eq(1); + + const invitationByInviterCountToday = await invitationManager.getInvitesByInviterOnDay( + inviter.address, + todayTimestamp + ); + await expect(invitationByInviterCountToday).to.be.eq(1); + + const yesterdayTimestamp = todayTimestamp - 24 * 60 * 60; + const invitationCountYesterday = await invitationManager.getTotalInvitesOnDay(yesterdayTimestamp); + await expect(invitationCountYesterday).to.be.eq(0); + + const invitationByInviterCountYesterday = await invitationManager.getInvitesByInviterOnDay( + inviter.address, + yesterdayTimestamp + ); + await expect(invitationByInviterCountYesterday).to.be.eq(0); + }); + + it("Should emit the right event when invitation is accepted", async () => { + const sig = await getInvitationSig(inviter); + + const hasBeenVerifiedBefore = await invitationManager.hasBeenVerified(invitee.address); + await expect(hasBeenVerifiedBefore).to.be.eq(false); + + const tx = await invitationManager.connect(invitee).confirmAndAcceptInvitation(inviter.address, sig); + const rec = await tx.wait(); + const event = rec.events?.find(e => e.event === "InvitationAccepted"); + const block = await ethers.provider.getBlock(rec.blockNumber); + + expect(event?.args?.inviter).to.be.eq(inviter.address); + expect(event?.args?.invitee).to.be.eq(invitee.address); + expect(event?.args?.index).to.be.eq(0); + expect(event?.args?.acceptedAt).to.be.eq(block.timestamp); + const inviteeByIndex = await invitationManager.getInviteeAtIndex(inviter.address, event?.args?.index); + expect(inviteeByIndex).to.be.eq(invitee.address); + + const tx2 = await invitationManager.connect(addrs[0]).confirmAndAcceptInvitation(inviter.address, sig); + const rec2 = await tx2.wait(); + const event2 = rec2.events?.find(e => e.event === "InvitationAccepted"); + const block2 = await ethers.provider.getBlock(rec2.blockNumber); + + await expect(event2?.args?.inviter).to.be.eq(inviter.address); + await expect(event2?.args?.invitee).to.be.eq(addrs[0].address); + await expect(event2?.args?.index).to.be.eq(1); + await expect(event2?.args?.acceptedAt).to.be.eq(block2.timestamp); + const inviteeByIndex2 = await invitationManager.getInviteeAtIndex(inviter.address, event2?.args?.index); + expect(inviteeByIndex2).to.be.eq(addrs[0].address); + }); + }); +}); diff --git a/packages/zevm-app-contracts/test/zeta-points/UserVerificationRegistry.ts b/packages/zevm-app-contracts/test/zeta-points/UserVerificationRegistry.ts new file mode 100644 index 00000000..edd75e32 --- /dev/null +++ b/packages/zevm-app-contracts/test/zeta-points/UserVerificationRegistry.ts @@ -0,0 +1,41 @@ +import { expect, use } from "chai"; +import { solidity } from "ethereum-waffle"; +use(solidity); +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { ethers } from "hardhat"; + +import { InvitationManager } from "../../typechain-types"; + +describe("UserVerificationRegistry Contract test", () => { + let invitationManager: InvitationManager, user: SignerWithAddress, addrs: SignerWithAddress[]; + + beforeEach(async () => { + [user, ...addrs] = await ethers.getSigners(); + const InvitationManagerFactory = await ethers.getContractFactory("InvitationManager"); + //@ts-ignore + invitationManager = await InvitationManagerFactory.deploy(); + }); + + describe("True", () => { + it("Should be true", async () => { + expect(true).to.equal(true); + }); + }); + + describe("Vereification test", () => { + it("Should be able to verify a wallet", async () => { + const hasBeenVerified = await invitationManager.hasBeenVerified(user.address); + expect(hasBeenVerified).to.be.false; + + const tx = await invitationManager.markAsVerified(); + const receipt = await tx.wait(); + const block = await ethers.provider.getBlock(receipt.blockNumber); + + const hasBeenVerifiedAfter = await invitationManager.hasBeenVerified(user.address); + expect(hasBeenVerifiedAfter).to.be.true; + + const verification = await invitationManager.getVerifiedTimestamp(user.address); + expect(verification).to.be.eq(block.timestamp); + }); + }); +}); diff --git a/packages/zevm-app-contracts/test/zeta-points/test.helpers.ts b/packages/zevm-app-contracts/test/zeta-points/test.helpers.ts new file mode 100644 index 00000000..cb758210 --- /dev/null +++ b/packages/zevm-app-contracts/test/zeta-points/test.helpers.ts @@ -0,0 +1,12 @@ +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { ethers } from "hardhat"; + +export const getInvitationSig = async (signer: SignerWithAddress) => { + let payload = ethers.utils.defaultAbiCoder.encode(["address"], [signer.address]); + + let payloadHash = ethers.utils.keccak256(payload); + + // This adds the message prefix + let signature = await signer.signMessage(ethers.utils.arrayify(payloadHash)); + return ethers.utils.splitSignature(signature); +};