diff --git a/solidity/dashboard/package-lock.json b/solidity/dashboard/package-lock.json index a73bc6df45..f78013da6a 100644 --- a/solidity/dashboard/package-lock.json +++ b/solidity/dashboard/package-lock.json @@ -2442,9 +2442,9 @@ } }, "@keep-network/keep-core": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@keep-network/keep-core/-/keep-core-1.4.1.tgz", - "integrity": "sha512-X72hY4Xn65R49cFJIpmb5MtJeQtMEoymfx6o1b3MW876e29nO98dC91XOZPdPCATnzGolEJPRB70Pw9Y14WD7A==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@keep-network/keep-core/-/keep-core-1.7.0.tgz", + "integrity": "sha512-jU0ol4L5a7vFUXCTlYGsjZYhl87cUpiAYz9LgDgvM3sGmwNIVZ9dY3gziINXIbSSFZjoqh3eGDxDPcQmA+Rjrg==", "requires": { "@openzeppelin/upgrades": "^2.7.2", "openzeppelin-solidity": "2.4.0" diff --git a/solidity/dashboard/package.json b/solidity/dashboard/package.json index bd534227d3..25fed246d8 100644 --- a/solidity/dashboard/package.json +++ b/solidity/dashboard/package.json @@ -5,7 +5,7 @@ "license": "MIT", "dependencies": { "@0x/subproviders": "^6.0.8", - "@keep-network/keep-core": "1.4.1", + "@keep-network/keep-core": "1.7.0", "@keep-network/keep-ecdsa": "1.6.0", "@keep-network/tbtc": "1.1.0", "@ledgerhq/hw-app-eth": "^5.13.0", diff --git a/solidity/dashboard/src/actions/web3.js b/solidity/dashboard/src/actions/web3.js index 808b370817..d822a2ffcf 100644 --- a/solidity/dashboard/src/actions/web3.js +++ b/solidity/dashboard/src/actions/web3.js @@ -179,13 +179,16 @@ export const withdrawGroupMemberRewards = ( export const withdrawAllLiquidityRewards = ( liquidityPairContractName, + amount, + pool, meta ) => { return { - type: WEB3_SEND_TRANSACTION, + type: "liquidity_rewards/withdraw_tokens", payload: { contractName: liquidityPairContractName, - methodName: "exit", + amount, + pool, }, meta, } diff --git a/solidity/dashboard/src/components/AddETHModal.jsx b/solidity/dashboard/src/components/AddETHModal.jsx index fe3b5c5f0e..dee5819a04 100644 --- a/solidity/dashboard/src/components/AddETHModal.jsx +++ b/solidity/dashboard/src/components/AddETHModal.jsx @@ -1,5 +1,5 @@ import React, { useCallback } from "react" -import AvailableETHForm from "./AvailableETHForm" +import AvailableETHForm from "./AvailableTokenForm" import { getErrorsObj } from "../forms/common-validators" import { withFormik } from "formik" import web3Utils from "web3-utils" diff --git a/solidity/dashboard/src/components/AvailableETHForm.jsx b/solidity/dashboard/src/components/AvailableTokenForm.jsx similarity index 90% rename from solidity/dashboard/src/components/AvailableETHForm.jsx rename to solidity/dashboard/src/components/AvailableTokenForm.jsx index 665e58c28b..2bd6da949c 100644 --- a/solidity/dashboard/src/components/AvailableETHForm.jsx +++ b/solidity/dashboard/src/components/AvailableTokenForm.jsx @@ -4,10 +4,11 @@ import { useCustomOnSubmitFormik } from "../hooks/useCustomOnSubmitFormik" import FormInput from "./FormInput" import { colors } from "../constants/colors" -const AvailableETHForm = ({ +const AvailableTokenForm = ({ onSubmit, onCancel, submitBtnText, + formInputProps, ...formikProps }) => { const onSubmitBtn = useCustomOnSubmitFormik(onSubmit) @@ -19,6 +20,7 @@ const AvailableETHForm = ({ type="text" label="ETH Amount" placeholder="0" + {...formInputProps} />
{ - const { setFieldValue } = useFormikContext() + const onAddonClick = useSetMaxAmountToken("stakeTokens", availableToStake) - const onAddonClick = () => { - setFieldValue( - "stakeTokens", - toTokenUnit(availableToStake).toFixed(0, BigNumber.ROUND_DOWN) - ) - } return (
@@ -133,7 +127,9 @@ const TokensAmountField = ({ minStake )} KEEP`} leftIcon={} - inputAddon={} + inputAddon={ + + } /> { - return -} - const connectedWithFormik = withFormik({ mapPropsToValues: () => ({ beneficiaryAddress: "", diff --git a/solidity/dashboard/src/components/Icons.jsx b/solidity/dashboard/src/components/Icons.jsx index 56da2c824b..0e6444b0a4 100644 --- a/solidity/dashboard/src/components/Icons.jsx +++ b/solidity/dashboard/src/components/Icons.jsx @@ -50,6 +50,7 @@ export { ReactComponent as StakeDrop } from "../static/svg/stakedrop.svg" export { ReactComponent as SwordOperations } from "../static/svg/sword-operations.svg" export { ReactComponent as MoreInfo } from "../static/svg/more-info.svg" export { ReactComponent as EthToken } from "../static/svg/eth_token.svg" +export { ReactComponent as KeepOnlyPool } from "../static/svg/keep-only-pool.svg" export { ReactComponent as BalancerLogo } from "../static/svg/balancer-logo.svg" export { ReactComponent as UniswapLogo } from "../static/svg/uniswap-logo.svg" diff --git a/solidity/dashboard/src/components/KeepOnlyPool.jsx b/solidity/dashboard/src/components/KeepOnlyPool.jsx new file mode 100644 index 0000000000..d6d6544c63 --- /dev/null +++ b/solidity/dashboard/src/components/KeepOnlyPool.jsx @@ -0,0 +1,335 @@ +import React, { useMemo, useCallback } from "react" +import CountUp from "react-countup" +import { withFormik } from "formik" +import Divider from "./Divider" +import { SubmitButton } from "./Button" +import * as Icons from "./Icons" +import { APY } from "./liquidity" +import { gt, add, lte } from "../utils/arithmetics.utils" +import { + toTokenUnit, + displayAmount, + fromTokenUnit, + displayAmountWithMetricSuffix, + displayNumberWithMetricSuffix, +} from "../utils/token.utils" +import { + normalizeAmount, + formatAmount as formatFormAmount, +} from "../forms/form.utils.js" +import MaxAmountAddon from "./MaxAmountAddon" +import useSetMaxAmountToken from "../hooks/useSetMaxAmountToken" +import AvailableTokenForm from "./AvailableTokenForm" +import { validateAmountInRange, getErrorsObj } from "../forms/common-validators" +import { useModal } from "../hooks/useModal" +import TokenAmount from "./TokenAmount" +import MetricsTile from "./MetricsTile" +import { Skeleton } from "./skeletons" + +const KeepOnlyPool = ({ + apy, + lpBalance, + rewardBalance, + wrappedTokenBalance, + isFetching, + isAPYFetching, + addLpTokens, + withdrawLiquidityRewards, + liquidityContractName, + pool, +}) => { + const { openConfirmationModal } = useModal() + + const lockedKEEP = useMemo(() => { + return add(lpBalance, rewardBalance) + }, [lpBalance, rewardBalance]) + + const formattingFn = useCallback((value) => { + return displayAmount(fromTokenUnit(value)) + }, []) + + const addKEEP = useCallback( + async (awaitingPromise) => { + const { amount } = await openConfirmationModal( + { + modalOptions: { title: "Deposit KEEP" }, + availableAmount: wrappedTokenBalance, + }, + AddKEEPFormik + ) + + addLpTokens( + fromTokenUnit(amount).toString(), + liquidityContractName, + pool, + awaitingPromise + ) + }, + [ + addLpTokens, + liquidityContractName, + pool, + openConfirmationModal, + wrappedTokenBalance, + ] + ) + + const withdrawKEEP = useCallback( + async (awaitingPromise) => { + const { amount } = await openConfirmationModal( + { + modalOptions: { title: "Withdraw Locked KEEP" }, + availableAmount: lpBalance, + rewardedAmount: rewardBalance, + }, + WithdrawKEEPFormik + ) + + withdrawLiquidityRewards( + liquidityContractName, + fromTokenUnit(amount).toString(), + pool, + awaitingPromise + ) + }, + [ + withdrawLiquidityRewards, + lpBalance, + openConfirmationModal, + pool, + liquidityContractName, + rewardBalance, + ] + ) + + return ( +
+
+
+

Your KEEP Total Locked

+

+ +  KEEP +

+
+

Deposited KEEP tokens

+

+ +  KEEP +

+
+ +
+

Rewarded KEEP tokens

+

+ +  KEEP +

+
+ +
+ + {gt(lpBalance, 0) ? "add more keep" : "deposit keep"} + + + withdraw all + +
+
+
+ + + + + +
Estimate of pool apy
+
+ + {isFetching ? ( + + ) : ( +

+ +

+ )} +
your keep rewards
+
+
+
+
+
+ ) +} + +export default KeepOnlyPool + +const AddKEEPForm = (props) => { + const { availableAmount, onCancel, ...formikProps } = props + const setMaxAmount = useSetMaxAmountToken("amount", availableAmount) + + return ( + <> +

Amount available to deposit.

+ + , + }} + {...formikProps} + /> + + ) +} + +const WithdrawKEEPForm = (props) => { + const { availableAmount, rewardedAmount, onCancel, ...formikProps } = props + const setMaxAmount = useSetMaxAmountToken("amount", availableAmount) + + return ( + <> +

Amount available to withdraw.

+
+ } + /> + + } + /> +
+ , + }} + {...formikProps} + /> + + ) +} + +const styles = { + amountTileWrapper: { + justifyContent: "flex-start", + flexGrow: "1", + padding: "0.5rem", + height: "auto", + }, +} +const AmountTile = ({ amount, title, icon }) => { + return ( + +
{title}
+
+ {icon} +   +

+ {displayAmountWithMetricSuffix(amount)} +  KEEP +

+
+
+ ) +} + +const commonFormikOptions = { + mapPropsToValues: () => ({ + amount: "0", + }), + validate: ({ amount }, { availableAmount }) => { + const errors = {} + + if (lte(availableAmount || 0, 0)) { + errors.amount = "Insufficient funds" + } else { + errors.amount = validateAmountInRange(amount, availableAmount, 1) + } + + return getErrorsObj(errors) + }, + handleSubmit: (values, { props }) => props.onBtnClick(values), +} +const WithdrawKEEPFormik = withFormik({ + ...commonFormikOptions, + displayName: "WithdrawKEEPFormik", +})(WithdrawKEEPForm) + +const AddKEEPFormik = withFormik({ + ...commonFormikOptions, + displayName: "AddKEEPFormik", +})(AddKEEPForm) diff --git a/solidity/dashboard/src/components/LiquidityRewardCard.jsx b/solidity/dashboard/src/components/LiquidityRewardCard.jsx index 55df2a563b..24f889dec9 100644 --- a/solidity/dashboard/src/components/LiquidityRewardCard.jsx +++ b/solidity/dashboard/src/components/LiquidityRewardCard.jsx @@ -1,6 +1,5 @@ -import React, { useMemo, useCallback } from "react" +import React, { useMemo } from "react" import CountUp from "react-countup" -import BigNumber from "bignumber.js" import DoubleIcon from "./DoubleIcon" import * as Icons from "./Icons" import { SubmitButton } from "./Button" @@ -10,8 +9,9 @@ import Tooltip from "./Tooltip" import Banner from "./Banner" import { toTokenUnit } from "../utils/token.utils" import { gt } from "../utils/arithmetics.utils" -import { formatPercentage } from "../utils/general.utils" import { LIQUIDITY_REWARD_PAIRS } from "../constants/constants" +import { APY, ShareOfPool } from "./liquidity" +import MetricsTile from "./MetricsTile" const LiquidityRewardCard = ({ title, @@ -35,36 +35,6 @@ const LiquidityRewardCard = ({ isAPYFetching, pool, }) => { - const formattedApy = useMemo(() => { - const bn = new BigNumber(apy).multipliedBy(100) - if (bn.isEqualTo(Infinity)) { - return Infinity - } else if (bn.isLessThan(0.01) && bn.isGreaterThan(0)) { - return 0.01 - } else if (bn.isGreaterThan(999)) { - return 999 - } - - return formatPercentage(bn) - }, [apy]) - - const formattedPercentageOfTotalPool = useMemo(() => { - const bn = new BigNumber(percentageOfTotalPool) - return bn.isLessThan(0.01) && bn.isGreaterThan(0) - ? 0.01 - : formatPercentage(bn) - }, [percentageOfTotalPool]) - - const formattingFn = useCallback((value) => { - let prefix = "" - if (value === 0.01) { - prefix = `<` - } else if (value >= 999) { - prefix = `>` - } - return `${prefix}${value}%` - }, []) - const hasWrappedTokens = useMemo(() => gt(wrappedTokenBalance, 0), [ wrappedTokenBalance, ]) @@ -147,61 +117,25 @@ const LiquidityRewardCard = ({ gt(lpBalance, 0) ? "" : "--locked" } mt-2 mb-2`} > -
- - Pool APY is calculated using the  - - Uniswap subgraph API - -  to fetch the total pool value and KEEP token in USD. - - {isAPYFetching ? ( - - ) : ( -

- {formattedApy === Infinity ? ( - - ) : ( - - )} -

- )} + + + + +
Estimate of pool apy
-
-
- {isFetching ? ( - - ) : ( -

- -

- )} + + +
Your share of POOL
-
+
{renderUserInfoBanner()}
@@ -247,7 +181,12 @@ const LiquidityRewardCard = ({ className={"liquidity__withdraw btn btn-secondary btn-lg w-100"} disabled={!gt(rewardBalance, 0) && !gt(lpBalance, 0)} onSubmitAction={(awaitingPromise) => - withdrawLiquidityRewards(liquidityPairContractName, awaitingPromise) + withdrawLiquidityRewards( + liquidityPairContractName, + lpBalance, + pool, + awaitingPromise + ) } > withdraw all diff --git a/solidity/dashboard/src/components/MaxAmountAddon.jsx b/solidity/dashboard/src/components/MaxAmountAddon.jsx new file mode 100644 index 0000000000..ab8b081949 --- /dev/null +++ b/solidity/dashboard/src/components/MaxAmountAddon.jsx @@ -0,0 +1,16 @@ +import React from "react" +import * as Icons from "./Icons" +import Tag from "./Tag" + +const MaxAmountAddon = ({ onClick, text, ...otherProps }) => { + return ( + + ) +} + +export default MaxAmountAddon diff --git a/solidity/dashboard/src/components/MetricsTile.jsx b/solidity/dashboard/src/components/MetricsTile.jsx new file mode 100644 index 0000000000..1a168ebf52 --- /dev/null +++ b/solidity/dashboard/src/components/MetricsTile.jsx @@ -0,0 +1,27 @@ +import React from "react" +import Tooltip from "./Tooltip" +import * as Icons from "./Icons" + +const MetricsTile = ({ className, children, style = {} }) => { + return ( +
+ {children} +
+ ) +} + +MetricsTile.Tooltip = ({ className, children, ...restTooltipProps }) => { + return ( + + {children} + + ) +} + +export default MetricsTile diff --git a/solidity/dashboard/src/components/WithdrawETHModal.jsx b/solidity/dashboard/src/components/WithdrawETHModal.jsx index 06d883820d..db261fcd2e 100644 --- a/solidity/dashboard/src/components/WithdrawETHModal.jsx +++ b/solidity/dashboard/src/components/WithdrawETHModal.jsx @@ -5,7 +5,7 @@ import web3Utils from "web3-utils" import { useWeb3Context } from "./WithWeb3Context" import * as Icons from "./Icons" import AvailableEthAmount from "./AvailableEthAmount" -import AvailableETHForm from "./AvailableETHForm" +import AvailableETHForm from "./AvailableTokenForm" import { withdrawUnbondedEth, withdrawUnbondedEthAsManagedGrantee, diff --git a/solidity/dashboard/src/components/liquidity/APY.jsx b/solidity/dashboard/src/components/liquidity/APY.jsx new file mode 100644 index 0000000000..dbd0342f55 --- /dev/null +++ b/solidity/dashboard/src/components/liquidity/APY.jsx @@ -0,0 +1,66 @@ +import React, { useMemo } from "react" +import CountUp from "react-countup" +import BigNumber from "bignumber.js" +import { Skeleton } from "../skeletons" +import { + displayPercentageValue, + formatPercentage, +} from "../../utils/general.utils" + +export const APY = ({ + apy, + isFetching = false, + skeletonProps = { tag: "h2", shining: true, color: "grey-10" }, + className = "", +}) => { + const formattedApy = useMemo(() => { + const bn = new BigNumber(apy).multipliedBy(100) + if (bn.isEqualTo(Infinity)) { + return Infinity + } else if (bn.isLessThan(0.01) && bn.isGreaterThan(0)) { + return 0.01 + } else if (bn.isGreaterThan(999)) { + return 999 + } + + return formatPercentage(bn) + }, [apy]) + + return isFetching ? ( + + ) : ( +

+ {formattedApy === Infinity ? ( + + ) : ( + + )} +

+ ) +} + +APY.TooltipContent = () => { + return ( + <> + Pool APY is calculated using the  + + Uniswap subgraph API + +  to fetch the total pool value and KEEP token in USD. + + ) +} + +export default APY diff --git a/solidity/dashboard/src/components/liquidity/ShareOfPool.jsx b/solidity/dashboard/src/components/liquidity/ShareOfPool.jsx new file mode 100644 index 0000000000..3eb90efa01 --- /dev/null +++ b/solidity/dashboard/src/components/liquidity/ShareOfPool.jsx @@ -0,0 +1,39 @@ +import React, { useMemo } from "react" +import CountUp from "react-countup" +import BigNumber from "bignumber.js" +import { Skeleton } from "../skeletons" +import { + displayPercentageValue, + formatPercentage, +} from "../../utils/general.utils" + +const ShareOfPool = ({ + percentageOfTotalPool, + isFetching = false, + skeletonProps = { tag: "h2", shining: true, color: "grey-10" }, + className = "", +}) => { + const formattedPercentageOfTotalPool = useMemo(() => { + const bn = new BigNumber(percentageOfTotalPool) + return bn.isLessThan(0.01) && bn.isGreaterThan(0) + ? 0.01 + : formatPercentage(bn) + }, [percentageOfTotalPool]) + + return isFetching ? ( + + ) : ( +

+ +

+ ) +} + +export default ShareOfPool diff --git a/solidity/dashboard/src/components/liquidity/index.js b/solidity/dashboard/src/components/liquidity/index.js new file mode 100644 index 0000000000..12a5399c23 --- /dev/null +++ b/solidity/dashboard/src/components/liquidity/index.js @@ -0,0 +1,4 @@ +import APY from "./APY" +import ShareOfPool from "./ShareOfPool" + +export { APY, ShareOfPool } diff --git a/solidity/dashboard/src/constants/constants.js b/solidity/dashboard/src/constants/constants.js index ddf5b563d3..aa01109d2c 100644 --- a/solidity/dashboard/src/constants/constants.js +++ b/solidity/dashboard/src/constants/constants.js @@ -18,6 +18,7 @@ export const LP_REWARDS_TBTC_SADDLE_CONTRACT_NAME = "LPRewardsTBTCSaddle" export const LP_REWARDS_KEEP_ETH_CONTRACT_NAME = "LPRewardsKEEPETHContract" export const LP_REWARDS_TBTC_ETH_CONTRACT_NAME = "LPRewardsTBTCETHContract" export const LP_REWARDS_KEEP_TBTC_CONTRACT_NAME = "LPRewardsKEEPTBTCContract" +export const KEEP_TOKEN_GEYSER_CONTRACT_NAME = "keepTokenGeyserContract" export const PENDING_STATUS = "PENDING" export const COMPLETE_STATUS = "COMPLETE" @@ -72,4 +73,9 @@ export const LIQUIDITY_REWARD_PAIRS = { address: "0x854056fd40c1b52037166285b2e54fee774d33f6", pool: "UNISWAP", }, + KEEP_ONLY: { + contractName: KEEP_TOKEN_GEYSER_CONTRACT_NAME, + label: "KEEP", + pool: "TOKEN_GEYSER", + }, } diff --git a/solidity/dashboard/src/contracts.js b/solidity/dashboard/src/contracts.js index 537b944f98..33fe25b463 100644 --- a/solidity/dashboard/src/contracts.js +++ b/solidity/dashboard/src/contracts.js @@ -21,6 +21,7 @@ import LPRewardsKEEPETH from "@keep-network/keep-ecdsa/artifacts/LPRewardsKEEPET import LPRewardsTBTCETH from "@keep-network/keep-ecdsa/artifacts/LPRewardsTBTCETH.json" import LPRewardsKEEPTBTC from "@keep-network/keep-ecdsa/artifacts/LPRewardsKEEPTBTC.json" import LPRewardsTBTCSaddle from "@keep-network/keep-ecdsa/artifacts/LPRewardsTBTCSaddle.json" +import KeepOnlyPool from "@keep-network/keep-core/artifacts/KeepTokenGeyser.json" import IERC20 from "@keep-network/keep-core/artifacts/IERC20.json" import SaddleSwap from "./contracts-artifacts/SaddleSwap.json" import Web3 from "web3" @@ -44,6 +45,7 @@ import { LP_REWARDS_TBTC_ETH_CONTRACT_NAME, LP_REWARDS_KEEP_TBTC_CONTRACT_NAME, LP_REWARDS_TBTC_SADDLE_CONTRACT_NAME, + KEEP_TOKEN_GEYSER_CONTRACT_NAME, } from "./constants/constants" export const CONTRACT_DEPLOY_BLOCK_NUMBER = { @@ -125,6 +127,10 @@ const contracts = { artifact: LPRewardsTBTCSaddle, withDeployBlock: true, }, + [KEEP_TOKEN_GEYSER_CONTRACT_NAME]: { + artifact: KeepOnlyPool, + withDeployBlock: true, + }, } export async function getKeepTokenContractDeployerAddress(web3) { diff --git a/solidity/dashboard/src/css/commons.less b/solidity/dashboard/src/css/commons.less index 6ec993d09c..3e900c20d0 100644 --- a/solidity/dashboard/src/css/commons.less +++ b/solidity/dashboard/src/css/commons.less @@ -25,6 +25,38 @@ padding: 2rem; margin-bottom: 1.2rem; .box-shadow(0px, 4px, 4px,rgba(196, 196, 196, 0.3)); + + &--metrics { + background-color: @color-grey-20; + transition: background-color 0.5s ease; + text-align: center; + border-radius: 8px; + padding: 1rem; + margin: 0 0.2rem; + height: 9rem; + width: 10rem; + display: flex; + flex-direction: column; + justify-content: center; + position: relative; + + &__tooltip { + &.tooltip--simple { + &--top, &--bottom { + width: 12px; + height: 12px; + position: absolute; + top: 0; + right: 8px; + + .tooltip__content-wrapper { + text-align: left; + min-width: 16rem; + } + } + } + } + } } //background colors @@ -284,7 +316,7 @@ svg.wallet-icon { } } -svg.time-icon, svg.success-icon, svg.keep-outline, svg.tbtc-icon { +svg.time-icon, svg.success-icon, svg.keep-outline, svg.tbtc-icon, svg.reward-icon { &--black { path { fill: @color-black; diff --git a/solidity/dashboard/src/css/liquidity-page.less b/solidity/dashboard/src/css/liquidity-page.less index 8fea003dbd..1411ddbfca 100644 --- a/solidity/dashboard/src/css/liquidity-page.less +++ b/solidity/dashboard/src/css/liquidity-page.less @@ -51,33 +51,6 @@ .liquidity__info, .liquidity__info--locked { display: flex; justify-content: center; - - .liquidity__info-tile { - transition: background-color 0.5s ease; - text-align: center; - border-radius: 8px; - padding: 1rem; - margin: 0 0.2rem; - height: 9rem; - width: 10rem; - display: flex; - flex-direction: column; - justify-content: center; - position: relative; - - .liquidity__info-tile__tooltip { - width: 12px; - height: 12px; - position: absolute; - top: 0; - right: 8px; - - .tooltip__content-wrapper { - text-align: left; - min-width: 16rem; - } - } - } } .liquidity__new-user-info { @@ -108,15 +81,6 @@ } } - .liquidity__info--locked { - .liquidity__info-tile { - &:extend(.bg-grey-20); - .liquidity__info-tile__title { - color: @color-grey-60!important; - } - } - } - .liquidity__reward-balance { margin: 2rem 0; @@ -155,6 +119,73 @@ } +.liquidity__info--locked { + .liquidity__info-tile { + &:extend(.bg-grey-20); + .liquidity__info-tile__title { + color: @color-grey-60!important; + } + } +} + + + +.keep-only-pool { + display: flex; + flex-direction: column; + margin: 0 3.2rem; + + &__overview { + flex: 1 0 0; + display: flex; + flex-direction: column; + margin-bottom: 0; + + > section:first-of-type { + flex: 1 1 auto; + margin-right: 2rem; + } + + &__info-tiles { + display: flex; + justify-content: space-evenly; + margin-top: 1rem; + + > .liquidity__info-tile:first-of-type { + margin: 0; + margin-bottom: 1rem; + } + } + } + + &__icon { + background: url("../static/svg/keep-only-pool.svg"); + background-color: @color-grey-10; + background-size: cover; + background-position: center center; + background-repeat:no-repeat; + height: 400px; + } + + @media screen and (min-width: 1000px) { + flex-direction: row; + &__overview { + flex-direction: row; + margin-bottom: 1.2rem; + + &__info-tiles { + display: block; + } + } + + &__icon { + flex-grow: 0.5; + height: auto; + margin-bottom: 1.2rem; + } + } +} + .empty-page--liquidity-page { display: grid; gap: 1rem; diff --git a/solidity/dashboard/src/css/typography.less b/solidity/dashboard/src/css/typography.less index 7eb31b5fb5..2e820faf8a 100644 --- a/solidity/dashboard/src/css/typography.less +++ b/solidity/dashboard/src/css/typography.less @@ -215,6 +215,10 @@ label, .text-label { text-align: right; } + &-left { + text-align: left; + } + &-caption { font-weight: 500; font-size: 0.5rem; diff --git a/solidity/dashboard/src/hooks/useSetMaxAmountToken.js b/solidity/dashboard/src/hooks/useSetMaxAmountToken.js new file mode 100644 index 0000000000..665cfb8d90 --- /dev/null +++ b/solidity/dashboard/src/hooks/useSetMaxAmountToken.js @@ -0,0 +1,18 @@ +import BigNumber from "bignumber.js" +import { useFormikContext } from "formik" +import { toTokenUnit } from "../utils/token.utils" + +const useSetMaxAmountToken = (filedName, availableAmount) => { + const { setFieldValue } = useFormikContext() + + const setMaxAvailableAmount = () => { + setFieldValue( + filedName, + toTokenUnit(availableAmount).toFixed(0, BigNumber.ROUND_DOWN) + ) + } + + return setMaxAvailableAmount +} + +export default useSetMaxAmountToken diff --git a/solidity/dashboard/src/pages/liquidity/LiquidityPage.jsx b/solidity/dashboard/src/pages/liquidity/LiquidityPage.jsx index 61ff8ddbb5..73ed5ace36 100644 --- a/solidity/dashboard/src/pages/liquidity/LiquidityPage.jsx +++ b/solidity/dashboard/src/pages/liquidity/LiquidityPage.jsx @@ -17,14 +17,17 @@ import Banner from "../../components/Banner" import { useHideComponent } from "../../hooks/useHideComponent" import { gt } from "../../utils/arithmetics.utils" +import KeepOnlyPool from "../../components/KeepOnlyPool" + const LiquidityPage = ({ headerTitle }) => { const [isBannerVisible, hideBanner] = useHideComponent(false) const { isConnected } = useWeb3Context() const keepTokenBalance = useSelector((state) => state.keepTokenBalance) - const { TBTC_SADDLE, KEEP_ETH, TBTC_ETH, KEEP_TBTC } = useSelector( + const { TBTC_SADDLE, KEEP_ETH, TBTC_ETH, KEEP_TBTC, KEEP_ONLY } = useSelector( (state) => state.liquidityRewards ) + const dispatch = useDispatch() const address = useWeb3Address() @@ -66,10 +69,17 @@ const LiquidityPage = ({ headerTitle }) => { const withdrawLiquidityRewards = ( liquidityPairContractName, + amount, + pool, awaitingPromise ) => { dispatch( - withdrawAllLiquidityRewards(liquidityPairContractName, awaitingPromise) + withdrawAllLiquidityRewards( + liquidityPairContractName, + amount, + pool, + awaitingPromise + ) ) } @@ -111,6 +121,15 @@ const LiquidityPage = ({ headerTitle }) => { )} + { @@ -55,11 +56,6 @@ const liquidityRewardsReducer = (state = initialState, action) => { }, } case `liquidity_rewards/${liquidityRewardPairName}_staked`: { - const lpBalance = add( - state[liquidityRewardPairName].lpBalance, - restPayload.amount - ).toString() - return { ...state, [liquidityRewardPairName]: { @@ -68,9 +64,9 @@ const liquidityRewardsReducer = (state = initialState, action) => { state[liquidityRewardPairName].wrappedTokenBalance, restPayload.amount ).toString(), - lpBalance, + lpBalance: restPayload.lpBalance, shareOfPoolInPercent: percentageOf( - lpBalance, + restPayload.lpBalance, restPayload.totalSupply ).toString(), reward: restPayload.reward, @@ -79,11 +75,6 @@ const liquidityRewardsReducer = (state = initialState, action) => { } } case `liquidity_rewards/${liquidityRewardPairName}_withdrawn`: { - const lpBalance = sub( - state[liquidityRewardPairName].lpBalance, - restPayload.amount - ).toString() - return { ...state, [liquidityRewardPairName]: { @@ -92,9 +83,9 @@ const liquidityRewardsReducer = (state = initialState, action) => { state[liquidityRewardPairName].wrappedTokenBalance, restPayload.amount ).toString(), - lpBalance, + lpBalance: restPayload.lpBalance, shareOfPoolInPercent: percentageOf( - lpBalance, + restPayload.lpBalance, restPayload.totalSupply ).toString(), reward: restPayload.reward, diff --git a/solidity/dashboard/src/sagas/liquidity-rewards.js b/solidity/dashboard/src/sagas/liquidity-rewards.js index bedbf0cb9a..5f0a4a7e7a 100644 --- a/solidity/dashboard/src/sagas/liquidity-rewards.js +++ b/solidity/dashboard/src/sagas/liquidity-rewards.js @@ -56,7 +56,8 @@ function* fetchLiquidityRewardsData(liquidityRewardPair, address) { // Fetching available reward balance from `LPRewards` contract. reward = yield call( [LiquidityRewards, LiquidityRewards.rewardBalance], - address + address, + lpBalance ) // % of total pool in the `LPRewards` contract. shareOfPoolInPercent = percentageOf(lpBalance, totalSupply).toString() @@ -114,8 +115,8 @@ function* stakeTokens(action) { yield call(sendTransaction, { payload: { contract: LiquidityRewards.LPRewardsContract, - methodName: "stake", - args: [amount], + methodName: LiquidityRewards.stakeFnName, + args: LiquidityRewards.stakeArgs(amount), }, }) } @@ -186,3 +187,26 @@ export function* watchFetchLiquidityRewardsAPY() { fetchAllLiquidityRewardsAPY ) } + +function* withdrawTokens(action) { + const { contractName, amount, pool } = action.payload + + /** @type LiquidityRewards */ + const LiquidityRewards = yield getLPRewardsWrapper({ contractName, pool }) + + yield call(sendTransaction, { + payload: { + contract: LiquidityRewards.LPRewardsContract, + methodName: LiquidityRewards.withdrawTokensFnName, + args: LiquidityRewards.withdrawTokensArgs(amount), + }, + }) +} + +function* withdrawTokensWorker(action) { + yield call(submitButtonHelper, withdrawTokens, action) +} + +export function* watchWithdrawTokens() { + yield takeEvery("liquidity_rewards/withdraw_tokens", withdrawTokensWorker) +} diff --git a/solidity/dashboard/src/sagas/subscriptions.js b/solidity/dashboard/src/sagas/subscriptions.js index 4975093ad9..af70a77d8e 100644 --- a/solidity/dashboard/src/sagas/subscriptions.js +++ b/solidity/dashboard/src/sagas/subscriptions.js @@ -612,7 +612,7 @@ function* observeLiquidityTokenStakedEvent(liquidityRewardPair) { const contractEventCahnnel = yield call( createSubcribeToContractEventChannel, LiquidityRewards.LPRewardsContract, - "Staked" + LiquidityRewards.stakedEventName ) while (true) { @@ -640,7 +640,7 @@ function* observeLiquidityTokenWithdrawnEvent(liquidityRewardPair) { const contractEventCahnnel = yield call( createSubcribeToContractEventChannel, LiquidityRewards.LPRewardsContract, - "Withdrawn" + LiquidityRewards.depositWithdrawnEventName ) while (true) { @@ -681,9 +681,23 @@ function* lpTokensStakedOrWithdrawn( totalSupply ) + const { lpBalance } = yield select( + (state) => state.liquidityRewards[liquidityRewardPairName] + ) + + let updatedlpBalance = lpBalance + let emittedAmountValue = 0 + // Update only if this transacion relates to the current logged account. + if (isSameEthAddress(defaultAccount, user)) { + emittedAmountValue = amount + const arithmeticOpration = actionType.includes("withdrawn") ? sub : add + updatedlpBalance = arithmeticOpration(lpBalance, amount).toString() + } + const reward = yield call( [LiquidityRewards, LiquidityRewards.rewardBalance], - defaultAccount + defaultAccount, + updatedlpBalance ) // If the `Withdrawn` or `Staked` event was emitted the total pool of the LPRewards, @@ -691,8 +705,8 @@ function* lpTokensStakedOrWithdrawn( yield put({ type: actionType, payload: { - // Update only if this transacion relates to the current logged account. - amount: isSameEthAddress(defaultAccount, user) ? amount : 0, + amount: emittedAmountValue, + lpBalance: updatedlpBalance, totalSupply, reward, apy, @@ -712,16 +726,19 @@ function* observeLiquidityRewardPaidEvent(liquidityRewardPair) { const contractEventCahnnel = yield call( createSubcribeToContractEventChannel, LiquidityRewards.LPRewardsContract, - "RewardPaid" + LiquidityRewards.rewardClaimedEventName ) while (true) { try { - const { - returnValues: { user, reward }, - } = yield take(contractEventCahnnel) - - if (isSameEthAddress(defaultAccount, user)) { + const { returnValues } = yield take(contractEventCahnnel) + // LPRewards and TokenGeyser contract have different param names in an + // emitted event which is triggered when the reward is claimed but param + // which points to claimed reward amount is at the same index- 1. So we + // can get claimed amount by index eg. `event.returnValues["1"]`. + const reward = returnValues["1"] + + if (isSameEthAddress(defaultAccount, returnValues.user)) { yield put({ type: `liquidity_rewards/${liquidityRewardPair.name}_reward_paid`, payload: { diff --git a/solidity/dashboard/src/services/liquidity-rewards.js b/solidity/dashboard/src/services/liquidity-rewards.js index 919709a9c8..35f143261e 100644 --- a/solidity/dashboard/src/services/liquidity-rewards.js +++ b/solidity/dashboard/src/services/liquidity-rewards.js @@ -1,7 +1,11 @@ import web3Utils from "web3-utils" -import { createERC20Contract, createSaddleSwapContract } from "../contracts" +import { + createERC20Contract, + createSaddleSwapContract, + CONTRACT_DEPLOY_BLOCK_NUMBER, +} from "../contracts" import BigNumber from "bignumber.js" -import { toTokenUnit } from "../utils/token.utils" +import { toTokenUnit, fromTokenUnit } from "../utils/token.utils" import { getPairData, getKeepTokenPriceInUSD, @@ -9,6 +13,7 @@ import { } from "./uniswap-api" import moment from "moment" import { add } from "../utils/arithmetics.utils" +import { isEmptyArray } from "../utils/array.utils" /** @typedef {import("web3").default} Web3 */ /** @typedef {LiquidityRewards} LiquidityRewards */ @@ -17,6 +22,10 @@ const LPRewardsToWrappedTokenCache = {} const WEEKS_IN_YEAR = 52 class LiquidityRewards { + static async _getWrappedTokenAddress(LPRewardsContract) { + return await LPRewardsContract.methods.wrappedToken().call() + } + constructor(_wrappedTokenContract, _LPRewardsContract, _web3) { this.wrappedToken = _wrappedTokenContract this.LPRewardsContract = _LPRewardsContract @@ -31,6 +40,34 @@ class LiquidityRewards { return this.LPRewardsContract.options.address } + get rewardClaimedEventName() { + return "RewardPaid" + } + + get depositWithdrawnEventName() { + return "Withdrawn" + } + + get withdrawTokensFnName() { + return "exit" + } + + withdrawTokensArgs() { + return [] + } + + get stakedEventName() { + return "Staked" + } + + get stakeFnName() { + return "stake" + } + + stakeArgs(amount) { + return [amount] + } + wrappedTokenBalance = async (address) => { return await this.wrappedToken.methods.balanceOf(address).call() } @@ -188,20 +225,118 @@ class SaddleLPRewards extends LiquidityRewards { } } +class TokenGeyserLPRewards extends LiquidityRewards { + static async _getWrappedTokenAddress(LPRewardsContract) { + return await LPRewardsContract.methods.token().call() + } + + get rewardClaimedEventName() { + return "TokensClaimed" + } + + get depositWithdrawnEventName() { + return "Unstaked" + } + + get withdrawTokensFnName() { + return "unstake" + } + + withdrawTokensArgs(amount) { + return [amount, []] + } + + stakeArgs(amount) { + return [amount, []] + } + + stakedBalance = async (address) => { + return await this.LPRewardsContract.methods.totalStakedFor(address).call() + } + + totalSupply = async () => { + return await this.LPRewardsContract.methods.totalStaked().call() + } + + rewardBalance = async (address, amount) => { + try { + // The `TokenGeyser.unstakeQuery` throws an error in case when eg. the + // amount param is greater than the real user's stake or when + // the user stakes KEEP in block `X` and call unstakeQuery in block `X` + // (`SafeMath: division by zero` error is thrown.). The web3 parses the + // error message in the wrong way when the `hanleRevert` option is enabled + // [1]. So here we clone the rewards contract instance and disable the + // `hanldeRevert` option. + // References: [1]: + // https://github.com/ChainSafe/web3.js/issues/3742 + const clonedLPRewardsContract = this.LPRewardsContract.clone() + clonedLPRewardsContract.handleRevert = false + return await clonedLPRewardsContract.methods.unstakeQuery(amount).call() + } catch (error) { + return 0 + } + } + + calculateAPY = async (totalSupplyOfLPRewards) => { + totalSupplyOfLPRewards = toTokenUnit(totalSupplyOfLPRewards) + + const rewardPoolPerWeek = await this.rewardPoolPerWeek() + const keepTokenInUSD = await getKeepTokenPriceInUSD() + + const lpRewardsPoolInUSD = totalSupplyOfLPRewards.multipliedBy( + keepTokenInUSD + ) + + const r = this._calculateR( + keepTokenInUSD, + rewardPoolPerWeek, + lpRewardsPoolInUSD + ) + + return this._calculateAPY(r, WEEKS_IN_YEAR) + } + + rewardPoolPerWeek = async () => { + const tokensLockedEvents = await this.LPRewardsContract.getPastEvents( + "TokensLocked", + { + fromBlock: CONTRACT_DEPLOY_BLOCK_NUMBER.keepTokenGeyserContract, + } + ) + + // The KEEP-only pool will earn 100k KEEP per month. + let rewardPoolPerMonth = fromTokenUnit(10e4) + const weeksInMonth = new BigNumber( + moment.duration(1, "months").asSeconds() + ).div(moment.duration(7, "days").asSeconds()) + + if (!isEmptyArray(tokensLockedEvents)) { + rewardPoolPerMonth = new BigNumber( + tokensLockedEvents.reverse()[0].returnValues.amount + ) + } + + return toTokenUnit(rewardPoolPerMonth.div(weeksInMonth)) + } +} + const LiquidityRewardsPoolStrategy = { UNISWAP: UniswapLPRewards, SADDLE: SaddleLPRewards, + TOKEN_GEYSER: TokenGeyserLPRewards, } export class LiquidityRewardsFactory { /** * - * @param {('UNISWAP' | 'SADDLE')} pool - The supported type of pools. + * @param {('UNISWAP' | 'SADDLE' | 'TOKEN_GEYSER')} pool - The supported type of pools. * @param {Object} LPRewardsContract - The LPRewardsContract as web3 contract instance. * @param {Web3} web3 - web3 * @return {LiquidityRewards} - The Liquidity Rewards Wrapper */ static async initialize(pool, LPRewardsContract, web3) { + const PoolStrategy = LiquidityRewardsPoolStrategy[pool] + const lpRewardsContractAddress = web3Utils.toChecksumAddress( LPRewardsContract.options.address ) @@ -209,9 +344,9 @@ export class LiquidityRewardsFactory { if ( !LPRewardsToWrappedTokenCache.hasOwnProperty(lpRewardsContractAddress) ) { - const wrappedTokenAddress = await LPRewardsContract.methods - .wrappedToken() - .call() + const wrappedTokenAddress = await PoolStrategy._getWrappedTokenAddress( + LPRewardsContract + ) LPRewardsToWrappedTokenCache[ lpRewardsContractAddress ] = wrappedTokenAddress @@ -222,8 +357,6 @@ export class LiquidityRewardsFactory { LPRewardsToWrappedTokenCache[lpRewardsContractAddress] ) - const PoolStrategy = LiquidityRewardsPoolStrategy[pool] - return new PoolStrategy(wrappedTokenContract, LPRewardsContract, web3) } } diff --git a/solidity/dashboard/src/static/svg/keep-only-pool.svg b/solidity/dashboard/src/static/svg/keep-only-pool.svg new file mode 100644 index 0000000000..e43a73cbee --- /dev/null +++ b/solidity/dashboard/src/static/svg/keep-only-pool.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/solidity/dashboard/src/utils/general.utils.js b/solidity/dashboard/src/utils/general.utils.js index ac89038eed..db79b93dab 100644 --- a/solidity/dashboard/src/utils/general.utils.js +++ b/solidity/dashboard/src/utils/general.utils.js @@ -74,3 +74,22 @@ export const formatPercentage = (value) => { return value.decimalPlaces(2, BigNumber.ROUND_DOWN).toNumber() } + +export const displayPercentageValue = ( + value, + isFormattedValue = true, + min = 0.01, + max = 999 +) => { + if (!isFormattedValue) { + value = formatPercentage(value) + } + + let prefix = "" + if (value > 0 && value <= min) { + prefix = `<` + } else if (value >= max) { + prefix = `>` + } + return `${prefix}${value}%` +} diff --git a/solidity/dashboard/src/utils/token.utils.js b/solidity/dashboard/src/utils/token.utils.js index 4f12928f0b..09b00300f4 100644 --- a/solidity/dashboard/src/utils/token.utils.js +++ b/solidity/dashboard/src/utils/token.utils.js @@ -84,3 +84,7 @@ export const getNumberWithMetricSuffix = (number) => { export const displayAmountWithMetricSuffix = (amount) => { return getNumberWithMetricSuffix(toTokenUnit(amount)).formattedValue } + +export const displayNumberWithMetricSuffix = (number) => { + return getNumberWithMetricSuffix(number).formattedValue +} diff --git a/solidity/migrations/2_deploy_contracts.js b/solidity/migrations/2_deploy_contracts.js index 051f52b146..de0464ee4a 100644 --- a/solidity/migrations/2_deploy_contracts.js +++ b/solidity/migrations/2_deploy_contracts.js @@ -45,6 +45,7 @@ const KeepRegistry = artifacts.require("./KeepRegistry.sol") const GasPriceOracle = artifacts.require("./GasPriceOracle.sol") const StakingPortBacker = artifacts.require("./StakingPortBacker.sol") const BeaconRewards = artifacts.require("./BeaconRewards.sol") +const KeepTokenGeyser = artifacts.require("./geyser/KeepTokenGeyser.sol") let initializationPeriod = 43200 // ~12 hours const dkgContributionMargin = 1 // 1% @@ -161,4 +162,23 @@ module.exports = async function (deployer, network) { KeepRandomBeaconOperator.address, TokenStaking.address ) + + // KEEP token geyser contract + const maxUnlockSchedules = 12 + const startBonus = 30 // 30% + const bonusPeriodSec = 2592000 // 30 days in seconds + const initialSharesPerToken = 1 + const durationSec = 2592000 // 30 days in seconds + + await deployer.deploy( + KeepTokenGeyser, + // KEEP token is a distribution and staking token. + KeepToken.address, + KeepToken.address, + maxUnlockSchedules, + startBonus, + bonusPeriodSec, + initialSharesPerToken, + durationSec + ) } diff --git a/solidity/scripts/keep-token-geyser-init.js b/solidity/scripts/keep-token-geyser-init.js new file mode 100644 index 0000000000..aa428427cd --- /dev/null +++ b/solidity/scripts/keep-token-geyser-init.js @@ -0,0 +1,58 @@ +const KeepTokenGeyser = artifacts.require("./geyser/KeepTokenGeyser.sol") +const KeepToken = artifacts.require("./KeepToken.sol") +const BatchedPhasedEscrow = artifacts.require("./BatchedPhasedEscrow") +const KeepTokenGeyserRewardsEscrowBeneficiary = artifacts.require( + "./KeepTokenGeyserRewardsEscrowBeneficiary" +) + +module.exports = async function () { + try { + const accounts = await web3.eth.getAccounts() + const keepToken = await KeepToken.deployed() + const tokenGeyser = await KeepTokenGeyser.deployed() + const rewardsAmount = web3.utils.toWei("100000", "ether") + + const owner = accounts[0] + + const initialEscrowBalance = web3.utils.toWei("500000", "ether") // 500k KEEP + + const escrow = await BatchedPhasedEscrow.new(keepToken.address, { + from: owner, + }) + + // Configure escrow beneficiary. + const escrowBeneficiary = await KeepTokenGeyserRewardsEscrowBeneficiary.new( + keepToken.address, + tokenGeyser.address, + { + from: owner, + } + ) + + await escrowBeneficiary.transferOwnership(escrow.address, { + from: owner, + }) + + await escrow.approveBeneficiary(escrowBeneficiary.address, { + from: owner, + }) + + await tokenGeyser.setRewardDistribution(escrowBeneficiary.address, { + from: owner, + }) + + await keepToken.approveAndCall(escrow.address, initialEscrowBalance, [], { + from: owner, + }) + + // Initiate withdraw. + await escrow.batchedWithdraw([escrowBeneficiary.address], [rewardsAmount], { + from: owner, + }) + } catch (err) { + console.error("unexpected error:", err) + process.exit(1) + } + + process.exit() +}