diff --git a/contracts/deploy/00-home-chain-arbitration.ts b/contracts/deploy/00-home-chain-arbitration.ts index bf5b961bb..e1a1372d6 100644 --- a/contracts/deploy/00-home-chain-arbitration.ts +++ b/contracts/deploy/00-home-chain-arbitration.ts @@ -119,6 +119,12 @@ const deployArbitration: DeployFunction = async (hre: HardhatRuntimeEnvironment) await execute("KlerosCore", { from: deployer, log: true }, "changeCurrencyRates", pnk, 12225583, 12); await execute("KlerosCore", { from: deployer, log: true }, "changeCurrencyRates", dai, 60327783, 11); await execute("KlerosCore", { from: deployer, log: true }, "changeCurrencyRates", weth, 1, 1); + + await deploy("MerkleRedeem", { + from: deployer, + args: [pnk], + log: true, + }); }; deployArbitration.tags = ["Arbitration"]; diff --git a/contracts/src/arbitration/MerkleRedeem.sol b/contracts/src/arbitration/MerkleRedeem.sol new file mode 100644 index 000000000..ad8ffaa38 --- /dev/null +++ b/contracts/src/arbitration/MerkleRedeem.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT + +/** + * Original code taken from: https://github.com/balancer-labs/erc20-redeemable/blob/13d478a043ec7bfce7abefe708d027dfe3e2ea84/merkle/contracts/MerkleRedeem.sol + * Only comments and events were added, some variable names changed for clarity and the compiler version was upgraded to 0.8.x. + */ +pragma solidity 0.8.18; + +import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @title Distribution of tokens in a recurrent fashion. +contract MerkleRedeem is Ownable { + // ************************************* // + // * Enums / Structs * // + // ************************************* // + + struct Claim { + uint week; // The week the claim is related to. + uint balance; // The amount being claimed. + bytes32[] merkleProof; // The merkle proof for the claim, sorted from the leaf to the root of the tree. + } + + // ************************************* // + // * Events * // + // ************************************* // + + /// @dev To be emitted when a claim is made. + /// @param _claimant The address of the claimant. + /// @param _balance The amount being claimed. + event Claimed(address _claimant, uint256 _balance); + + // ************************************* // + // * Storage * // + // ************************************* // + + IERC20 public token; // The address of the token being distributed. + mapping(uint => bytes32) public weekMerkleRoots; // The merkle roots of each week. weekMerkleRoots[week]. + mapping(uint => mapping(address => bool)) public claimed; // Keeps track of the claim status for the given period and claimant. claimed[period][claimant]. + + // ************************************* // + // * Constructor * // + // ************************************* // + + /// @param _token The address of the token being distributed. + constructor(address _token) { + token = IERC20(_token); + } + + // ************************************* // + // * State Modifiers * // + // ************************************* // + + /// @notice Seeds a new round for the airdrop. + /// @dev Will transfer tokens from the owner to this contract. + /// @param _week The airdrop week. + /// @param _merkleRoot The merkle root of the claims for that period. + /// @param _totalAllocation The amount of tokens allocated for the distribution. + function seedAllocations(uint _week, bytes32 _merkleRoot, uint _totalAllocation) external onlyOwner { + require(weekMerkleRoots[_week] == bytes32(0), "cannot rewrite merkle root"); + weekMerkleRoots[_week] = _merkleRoot; + + require(token.transferFrom(msg.sender, address(this), _totalAllocation), "ERR_TRANSFER_FAILED"); + } + + /// @notice Makes a claim for a given claimant in a week. + /// @param _liquidityProvider The address of the claimant. + /// @param _week The week for the claim. + /// @param _claimedBalance The amount being claimed. + /// @param _merkleProof The merkle proof for the claim, sorted from the leaf to the root of the tree. + function claimWeek( + address _liquidityProvider, + uint _week, + uint _claimedBalance, + bytes32[] memory _merkleProof + ) public { + require(!claimed[_week][_liquidityProvider]); + require(verifyClaim(_liquidityProvider, _week, _claimedBalance, _merkleProof), "Incorrect merkle proof"); + + claimed[_week][_liquidityProvider] = true; + disburse(_liquidityProvider, _claimedBalance); + } + + /// @notice Makes multiple claims for a given claimant. + /// @param _liquidityProvider The address of the claimant. + /// @param claims An array of claims containing the week, balance and the merkle proof. + function claimWeeks(address _liquidityProvider, Claim[] memory claims) public { + uint totalBalance = 0; + Claim memory claim; + for (uint i = 0; i < claims.length; i++) { + claim = claims[i]; + + require(!claimed[claim.week][_liquidityProvider]); + require( + verifyClaim(_liquidityProvider, claim.week, claim.balance, claim.merkleProof), + "Incorrect merkle proof" + ); + + totalBalance += claim.balance; + claimed[claim.week][_liquidityProvider] = true; + } + disburse(_liquidityProvider, totalBalance); + } + + /// @notice Gets the claim status for given claimant from `_begin` to `_end` weeks. + /// @param _liquidityProvider The address of the claimant. + /// @param _begin The week to start with (inclusive). + /// @param _end The week to end with (inclusive). + function claimStatus(address _liquidityProvider, uint _begin, uint _end) external view returns (bool[] memory) { + uint size = 1 + _end - _begin; + bool[] memory arr = new bool[](size); + for (uint i = 0; i < size; i++) { + arr[i] = claimed[_begin + i][_liquidityProvider]; + } + return arr; + } + + /// @notice Gets all merkle roots for from `_begin` to `_end` weeks. + /// @param _begin The week to start with (inclusive). + /// @param _end The week to end with (inclusive). + function merkleRoots(uint _begin, uint _end) external view returns (bytes32[] memory) { + uint size = 1 + _end - _begin; + bytes32[] memory arr = new bytes32[](size); + for (uint i = 0; i < size; i++) { + arr[i] = weekMerkleRoots[_begin + i]; + } + return arr; + } + + /// @notice Verifies a claim. + /// @param _liquidityProvider The address of the claimant. + /// @param _week The week for the claim. + /// @param _claimedBalance The amount being claimed. + /// @param _merkleProof The merkle proof for the claim, sorted from the leaf to the root of the tree. + function verifyClaim( + address _liquidityProvider, + uint _week, + uint _claimedBalance, + bytes32[] memory _merkleProof + ) public view returns (bool valid) { + bytes32 leaf = keccak256(abi.encodePacked(_liquidityProvider, _claimedBalance)); + return MerkleProof.verify(_merkleProof, weekMerkleRoots[_week], leaf); + } + + // ************************************* // + // * Internal * // + // ************************************* // + + /// @dev Effectively pays a claimant. + /// @param _liquidityProvider The address of the claimant. + /// @param _balance The amount being claimed. + function disburse(address _liquidityProvider, uint _balance) private { + if (_balance > 0) { + emit Claimed(_liquidityProvider, _balance); + require(token.transfer(_liquidityProvider, _balance), "ERR_TRANSFER_FAILED"); + } + } +} diff --git a/web/src/assets/svgs/icons/close.svg b/web/src/assets/svgs/icons/close.svg new file mode 100644 index 000000000..ab7bfa063 --- /dev/null +++ b/web/src/assets/svgs/icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/assets/svgs/icons/kleros.svg b/web/src/assets/svgs/icons/kleros.svg index e0dc1a0fd..a674a9235 100644 --- a/web/src/assets/svgs/icons/kleros.svg +++ b/web/src/assets/svgs/icons/kleros.svg @@ -1,3 +1,3 @@ - + diff --git a/web/src/components/Popup/ClaimedStakingRewards/ClaimedText.tsx b/web/src/components/Popup/ClaimedStakingRewards/ClaimedText.tsx new file mode 100644 index 000000000..729f82e90 --- /dev/null +++ b/web/src/components/Popup/ClaimedStakingRewards/ClaimedText.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import styled from "styled-components"; + +const StyledText = styled.text` + font-size: calc(20px + (24 - 20) * (min(max(100vw, 375px), 1250px) - 375px) / 875); + font-weight: 600; + color: ${({ theme }) => theme.primaryText}; + margin-top: 16px; +`; + +const ClaimedText: React.FC = () => { + return 🎉 Claimed! 🎉; +}; +export default ClaimedText; diff --git a/web/src/components/Popup/ClaimedStakingRewards/Close.tsx b/web/src/components/Popup/ClaimedStakingRewards/Close.tsx new file mode 100644 index 000000000..c784d8637 --- /dev/null +++ b/web/src/components/Popup/ClaimedStakingRewards/Close.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import styled from "styled-components"; +import Icon from "tsx:svgs/icons/close.svg"; + +const StyledIcon = styled(Icon)` + position: absolute; + width: 18px; + height: 18px; + align-self: flex-end; + cursor: pointer; + + path { + fill: ${({ theme }) => theme.stroke}; + } +`; + +interface IClose { + togglePopup: () => void; +} + +const Close: React.FC = ({ togglePopup }) => { + return CloseIcon; +}; +export default Close; diff --git a/web/src/components/Popup/ClaimedStakingRewards/Divider.tsx b/web/src/components/Popup/ClaimedStakingRewards/Divider.tsx new file mode 100644 index 000000000..388938795 --- /dev/null +++ b/web/src/components/Popup/ClaimedStakingRewards/Divider.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import styled from "styled-components"; + +const StyledHr = styled.hr` + display: flex; + border: none; + height: 0.5px; + background-color: ${({ theme }) => theme.stroke}; + margin: calc(8px + (18 - 8) * (min(max(100vw, 375px), 1250px) - 375px) / 875) 0px; + width: 100%; +`; + +const Divider: React.FC = () => { + return ; +}; +export default Divider; diff --git a/web/src/components/Popup/ClaimedStakingRewards/KlerosIcon.tsx b/web/src/components/Popup/ClaimedStakingRewards/KlerosIcon.tsx new file mode 100644 index 000000000..58e856269 --- /dev/null +++ b/web/src/components/Popup/ClaimedStakingRewards/KlerosIcon.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import styled from "styled-components"; +import Icon from "svgs/icons/kleros.svg"; + +const StyledIcon = styled(Icon)` + path { + fill: ${({ theme }) => theme.secondaryPurple}; + } + width: calc(120px + (160 - 120) * (min(max(100vw, 375px), 1250px) - 375px) / 875); + height: calc(132px + (140 - 132) * (min(max(100vw, 375px), 1250px) - 375px) / 875); +`; + +const KlerosIcon: React.FC = () => { + return ; +}; +export default KlerosIcon; diff --git a/web/src/components/Popup/ClaimedStakingRewards/QuantityClaimed.tsx b/web/src/components/Popup/ClaimedStakingRewards/QuantityClaimed.tsx new file mode 100644 index 000000000..177f36487 --- /dev/null +++ b/web/src/components/Popup/ClaimedStakingRewards/QuantityClaimed.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import styled from "styled-components"; + +const StyledText = styled.text` + font-size: calc(40px + (64 - 40) * (min(max(100vw, 375px), 1250px) - 375px) / 875); + font-weight: 600; + color: ${({ theme }) => theme.secondaryPurple}; + margin-top: 16px; +`; + +const QuantityClaimed: React.FC = () => { + return 1,000 PNK; +}; +export default QuantityClaimed; diff --git a/web/src/components/Popup/ClaimedStakingRewards/ReadMore.tsx b/web/src/components/Popup/ClaimedStakingRewards/ReadMore.tsx new file mode 100644 index 000000000..a060c89d4 --- /dev/null +++ b/web/src/components/Popup/ClaimedStakingRewards/ReadMore.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import styled from "styled-components"; +import RightArrow from "tsx:svgs/icons/arrow.svg"; + +const StyledLink = styled.a` + display: flex; + color: ${({ theme }) => theme.primaryBlue}; + font-size: 16px; + margin-top: 8px; + align-items: center; + gap: 8px; + + &:hover { + text-decoration: underline; + } +`; + +const StyledRightArrow = styled(RightArrow)` + path { + fill: ${({ theme }) => theme.primaryBlue}; + } +`; + +const ReadMore: React.FC = () => { + return ( + + Read more about the Juror Incentive Program + + + ); +}; +export default ReadMore; diff --git a/web/src/components/Popup/ClaimedStakingRewards/ThanksText.tsx b/web/src/components/Popup/ClaimedStakingRewards/ThanksText.tsx new file mode 100644 index 000000000..d47ddce42 --- /dev/null +++ b/web/src/components/Popup/ClaimedStakingRewards/ThanksText.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import styled from "styled-components"; + +const StyledText = styled.text` + font-size: 16px; + color: ${({ theme }) => theme.primaryText}; + margin-top: 16px; +`; + +const ThanksText: React.FC = () => { + return Thank you for being part of the Kleros community.; +}; +export default ThanksText; diff --git a/web/src/components/Popup/ClaimedStakingRewards/index.tsx b/web/src/components/Popup/ClaimedStakingRewards/index.tsx new file mode 100644 index 000000000..7baea3051 --- /dev/null +++ b/web/src/components/Popup/ClaimedStakingRewards/index.tsx @@ -0,0 +1,63 @@ +import React, { useRef } from "react"; +import styled, { css } from "styled-components"; +import { landscapeStyle } from "styles/landscapeStyle"; +import { useFocusOutside } from "hooks/useFocusOutside"; +import { Overlay } from "components/Overlay"; +import KlerosIcon from "./KlerosIcon"; +import ClaimedText from "./ClaimedText"; +import QuantityClaimed from "./QuantityClaimed"; +import Divider from "./Divider"; +import ThanksText from "./ThanksText"; +import ReadMore from "./ReadMore"; +import Close from "./Close"; + +const Container = styled.div` + display: flex; + position: relative; + width: 86vw; + flex-direction: column; + align-items: center; + background-color: ${({ theme }) => theme.whiteBackground}; + padding: calc(24px + (52 - 24) * (min(max(100vw, 375px), 1250px) - 375px) / 875); + padding-top: calc(24px + (48 - 24) * (min(max(100vw, 375px), 1250px) - 375px) / 875); + + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-height: 80vh; + overflow-y: auto; + z-index: 10; + + ${landscapeStyle( + () => css` + width: calc(300px + (862 - 300) * (min(max(100vw, 375px), 1250px) - 375px) / 875); + ` + )} +`; + +interface IClaimedStakingRewards { + toggleIsOpen: () => void; +} + +const ClaimedStakingRewards: React.FC = ({ toggleIsOpen }) => { + const containerRef = useRef(null); + useFocusOutside(containerRef, () => toggleIsOpen()); + + return ( + <> + + + + + + + + + + + + ); +}; + +export default ClaimedStakingRewards; diff --git a/web/src/components/Popup/Description/StakeWithdraw.tsx b/web/src/components/Popup/Description/StakeWithdraw.tsx index e781a462b..078353bc6 100644 --- a/web/src/components/Popup/Description/StakeWithdraw.tsx +++ b/web/src/components/Popup/Description/StakeWithdraw.tsx @@ -13,6 +13,9 @@ const Container = styled.div` `; const StyledKlerosLogo = styled(KlerosLogo)` + path { + fill: ${({ theme }) => theme.secondaryPurple}; + } width: 14px; height: 14px; `; diff --git a/web/src/layout/Header/navbar/DappList.tsx b/web/src/layout/Header/navbar/DappList.tsx index 9aca796d5..4a361b646 100644 --- a/web/src/layout/Header/navbar/DappList.tsx +++ b/web/src/layout/Header/navbar/DappList.tsx @@ -58,6 +58,12 @@ const Container = styled.div` )} `; +const StyledCourt = styled(Court)` + path { + fill: ${({ theme }) => theme.secondaryPurple}; + } +`; + const ItemsDiv = styled.div` display: grid; overflow-y: auto; @@ -74,7 +80,7 @@ const ItemsDiv = styled.div` const ITEMS = [ { text: "Court v1", - Icon: Court, + Icon: StyledCourt, url: "https://court.kleros.io/", }, { diff --git a/web/src/pages/Dashboard/JurorInfo/JurorRewards.tsx b/web/src/pages/Dashboard/JurorInfo/JurorRewards.tsx index 2c8653e5a..3df08ad89 100644 --- a/web/src/pages/Dashboard/JurorInfo/JurorRewards.tsx +++ b/web/src/pages/Dashboard/JurorInfo/JurorRewards.tsx @@ -13,6 +13,7 @@ const Container = styled.div` flex-direction: column; align-items: flex-start; width: auto; + gap: calc(8px + (32 - 8) * (min(max(100vw, 375px), 1250px) - 375px) / 875); `; const tooltipMsg = diff --git a/web/src/pages/Dashboard/JurorInfo/StakingRewards.tsx b/web/src/pages/Dashboard/JurorInfo/StakingRewards.tsx index 119d1d3e0..ffaee12a2 100644 --- a/web/src/pages/Dashboard/JurorInfo/StakingRewards.tsx +++ b/web/src/pages/Dashboard/JurorInfo/StakingRewards.tsx @@ -1,14 +1,17 @@ import React from "react"; import styled from "styled-components"; +import { useToggle } from "react-use"; import { Box as _Box, Button } from "@kleros/ui-components-library"; import TokenRewards from "./TokenRewards"; import WithHelpTooltip from "../WithHelpTooltip"; +import ClaimedStakingRewards from "components/Popup/ClaimedStakingRewards"; import { EnsureChain } from "components/EnsureChain"; const Container = styled.div` display: flex; flex-direction: column; align-items: flex-start; + gap: calc(8px + (32 - 8) * (min(max(100vw, 375px), 1250px) - 375px) / 875); `; const Box = styled(_Box)` @@ -16,9 +19,13 @@ const Box = styled(_Box)` justify-content: space-between; align-items: center; padding: 8px; - width: 270px; + padding-left: 20px; + width: calc(232px + (312 - 232) * (min(max(100vw, 375px), 1250px) - 375px) / 875); height: auto; + border: 1px solid ${({ theme }) => theme.stroke}; border-radius: 3px; + background-color: ${({ theme }) => theme.lightBlue}; + gap: calc(12px + (28 - 12) * (min(max(100vw, 375px), 1250px) - 375px) / 875); `; const UnclaimedContainer = styled.div` @@ -27,25 +34,36 @@ const UnclaimedContainer = styled.div` gap: 4px; `; +const StyledSmall = styled.small` + font-size: 16px; +`; + const ClaimPNK: React.FC = () => { + const [isClaimRewardsModalOpen, toggleClaimRewardsModal] = useToggle(false); + return ( - - - - 1,000 PNK - - -